massgen 0.0.3__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of massgen might be problematic. Click here for more details.

Files changed (268) hide show
  1. massgen/__init__.py +142 -8
  2. massgen/adapters/__init__.py +29 -0
  3. massgen/adapters/ag2_adapter.py +483 -0
  4. massgen/adapters/base.py +183 -0
  5. massgen/adapters/tests/__init__.py +0 -0
  6. massgen/adapters/tests/test_ag2_adapter.py +439 -0
  7. massgen/adapters/tests/test_agent_adapter.py +128 -0
  8. massgen/adapters/utils/__init__.py +2 -0
  9. massgen/adapters/utils/ag2_utils.py +236 -0
  10. massgen/adapters/utils/tests/__init__.py +0 -0
  11. massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
  12. massgen/agent_config.py +329 -55
  13. massgen/api_params_handler/__init__.py +10 -0
  14. massgen/api_params_handler/_api_params_handler_base.py +99 -0
  15. massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
  16. massgen/api_params_handler/_claude_api_params_handler.py +113 -0
  17. massgen/api_params_handler/_response_api_params_handler.py +130 -0
  18. massgen/backend/__init__.py +39 -4
  19. massgen/backend/azure_openai.py +385 -0
  20. massgen/backend/base.py +341 -69
  21. massgen/backend/base_with_mcp.py +1102 -0
  22. massgen/backend/capabilities.py +386 -0
  23. massgen/backend/chat_completions.py +577 -130
  24. massgen/backend/claude.py +1033 -537
  25. massgen/backend/claude_code.py +1203 -0
  26. massgen/backend/cli_base.py +209 -0
  27. massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
  28. massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
  29. massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
  30. massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
  31. massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
  32. massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
  33. massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
  34. massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
  35. massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
  36. massgen/backend/docs/inference_backend.md +257 -0
  37. massgen/backend/docs/permissions_and_context_files.md +1085 -0
  38. massgen/backend/external.py +126 -0
  39. massgen/backend/gemini.py +1850 -241
  40. massgen/backend/grok.py +40 -156
  41. massgen/backend/inference.py +156 -0
  42. massgen/backend/lmstudio.py +171 -0
  43. massgen/backend/response.py +1095 -322
  44. massgen/chat_agent.py +131 -113
  45. massgen/cli.py +1560 -275
  46. massgen/config_builder.py +2396 -0
  47. massgen/configs/BACKEND_CONFIGURATION.md +458 -0
  48. massgen/configs/README.md +559 -216
  49. massgen/configs/ag2/ag2_case_study.yaml +27 -0
  50. massgen/configs/ag2/ag2_coder.yaml +34 -0
  51. massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
  52. massgen/configs/ag2/ag2_gemini.yaml +27 -0
  53. massgen/configs/ag2/ag2_groupchat.yaml +108 -0
  54. massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
  55. massgen/configs/ag2/ag2_single_agent.yaml +21 -0
  56. massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
  57. massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
  58. massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
  59. massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
  60. massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
  61. massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
  62. massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
  63. massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
  64. massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
  65. massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
  66. massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
  67. massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
  68. massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
  69. massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
  70. massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
  71. massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
  72. massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
  73. massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
  74. massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
  75. massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
  76. massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
  77. massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
  78. massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
  79. massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
  80. massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
  81. massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
  82. massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
  83. massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
  84. massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
  85. massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
  86. massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
  87. massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
  88. massgen/configs/debug/skip_coordination_test.yaml +27 -0
  89. massgen/configs/debug/test_sdk_migration.yaml +17 -0
  90. massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
  91. massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
  92. massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
  93. massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
  94. massgen/configs/providers/claude/claude.yaml +14 -0
  95. massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
  96. massgen/configs/providers/local/lmstudio.yaml +11 -0
  97. massgen/configs/providers/openai/gpt5.yaml +46 -0
  98. massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
  99. massgen/configs/providers/others/grok_single_agent.yaml +19 -0
  100. massgen/configs/providers/others/zai_coding_team.yaml +108 -0
  101. massgen/configs/providers/others/zai_glm45.yaml +12 -0
  102. massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
  103. massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
  104. massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
  105. massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
  106. massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
  107. massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
  108. massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
  109. massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
  110. massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
  111. massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
  112. massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
  113. massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
  114. massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
  115. massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
  116. massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
  117. massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
  118. massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
  119. massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
  120. massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
  121. massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
  122. massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
  123. massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
  124. massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
  125. massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
  126. massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
  127. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
  128. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
  129. massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
  130. massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
  131. massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
  132. massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
  133. massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
  134. massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
  135. massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
  136. massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
  137. massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
  138. massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
  139. massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
  140. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
  141. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
  142. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
  143. massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
  144. massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
  145. massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
  146. massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
  147. massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
  148. massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
  149. massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
  150. massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
  151. massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
  152. massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
  153. massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
  154. massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
  155. massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
  156. massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
  157. massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
  158. massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
  159. massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
  160. massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
  161. massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
  162. massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
  163. massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
  164. massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
  165. massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
  166. massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
  167. massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
  168. massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
  169. massgen/coordination_tracker.py +708 -0
  170. massgen/docker/README.md +462 -0
  171. massgen/filesystem_manager/__init__.py +21 -0
  172. massgen/filesystem_manager/_base.py +9 -0
  173. massgen/filesystem_manager/_code_execution_server.py +545 -0
  174. massgen/filesystem_manager/_docker_manager.py +477 -0
  175. massgen/filesystem_manager/_file_operation_tracker.py +248 -0
  176. massgen/filesystem_manager/_filesystem_manager.py +813 -0
  177. massgen/filesystem_manager/_path_permission_manager.py +1261 -0
  178. massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
  179. massgen/formatter/__init__.py +10 -0
  180. massgen/formatter/_chat_completions_formatter.py +284 -0
  181. massgen/formatter/_claude_formatter.py +235 -0
  182. massgen/formatter/_formatter_base.py +156 -0
  183. massgen/formatter/_response_formatter.py +263 -0
  184. massgen/frontend/__init__.py +1 -2
  185. massgen/frontend/coordination_ui.py +471 -286
  186. massgen/frontend/displays/base_display.py +56 -11
  187. massgen/frontend/displays/create_coordination_table.py +1956 -0
  188. massgen/frontend/displays/rich_terminal_display.py +1259 -619
  189. massgen/frontend/displays/simple_display.py +9 -4
  190. massgen/frontend/displays/terminal_display.py +27 -68
  191. massgen/logger_config.py +681 -0
  192. massgen/mcp_tools/README.md +232 -0
  193. massgen/mcp_tools/__init__.py +105 -0
  194. massgen/mcp_tools/backend_utils.py +1035 -0
  195. massgen/mcp_tools/circuit_breaker.py +195 -0
  196. massgen/mcp_tools/client.py +894 -0
  197. massgen/mcp_tools/config_validator.py +138 -0
  198. massgen/mcp_tools/docs/circuit_breaker.md +646 -0
  199. massgen/mcp_tools/docs/client.md +950 -0
  200. massgen/mcp_tools/docs/config_validator.md +478 -0
  201. massgen/mcp_tools/docs/exceptions.md +1165 -0
  202. massgen/mcp_tools/docs/security.md +854 -0
  203. massgen/mcp_tools/exceptions.py +338 -0
  204. massgen/mcp_tools/hooks.py +212 -0
  205. massgen/mcp_tools/security.py +780 -0
  206. massgen/message_templates.py +342 -64
  207. massgen/orchestrator.py +1515 -241
  208. massgen/stream_chunk/__init__.py +35 -0
  209. massgen/stream_chunk/base.py +92 -0
  210. massgen/stream_chunk/multimodal.py +237 -0
  211. massgen/stream_chunk/text.py +162 -0
  212. massgen/tests/mcp_test_server.py +150 -0
  213. massgen/tests/multi_turn_conversation_design.md +0 -8
  214. massgen/tests/test_azure_openai_backend.py +156 -0
  215. massgen/tests/test_backend_capabilities.py +262 -0
  216. massgen/tests/test_backend_event_loop_all.py +179 -0
  217. massgen/tests/test_chat_completions_refactor.py +142 -0
  218. massgen/tests/test_claude_backend.py +15 -28
  219. massgen/tests/test_claude_code.py +268 -0
  220. massgen/tests/test_claude_code_context_sharing.py +233 -0
  221. massgen/tests/test_claude_code_orchestrator.py +175 -0
  222. massgen/tests/test_cli_backends.py +180 -0
  223. massgen/tests/test_code_execution.py +679 -0
  224. massgen/tests/test_external_agent_backend.py +134 -0
  225. massgen/tests/test_final_presentation_fallback.py +237 -0
  226. massgen/tests/test_gemini_planning_mode.py +351 -0
  227. massgen/tests/test_grok_backend.py +7 -10
  228. massgen/tests/test_http_mcp_server.py +42 -0
  229. massgen/tests/test_integration_simple.py +198 -0
  230. massgen/tests/test_mcp_blocking.py +125 -0
  231. massgen/tests/test_message_context_building.py +29 -47
  232. massgen/tests/test_orchestrator_final_presentation.py +48 -0
  233. massgen/tests/test_path_permission_manager.py +2087 -0
  234. massgen/tests/test_rich_terminal_display.py +14 -13
  235. massgen/tests/test_timeout.py +133 -0
  236. massgen/tests/test_v3_3agents.py +11 -12
  237. massgen/tests/test_v3_simple.py +8 -13
  238. massgen/tests/test_v3_three_agents.py +11 -18
  239. massgen/tests/test_v3_two_agents.py +8 -13
  240. massgen/token_manager/__init__.py +7 -0
  241. massgen/token_manager/token_manager.py +400 -0
  242. massgen/utils.py +52 -16
  243. massgen/v1/agent.py +45 -91
  244. massgen/v1/agents.py +18 -53
  245. massgen/v1/backends/gemini.py +50 -153
  246. massgen/v1/backends/grok.py +21 -54
  247. massgen/v1/backends/oai.py +39 -111
  248. massgen/v1/cli.py +36 -93
  249. massgen/v1/config.py +8 -12
  250. massgen/v1/logging.py +43 -127
  251. massgen/v1/main.py +18 -32
  252. massgen/v1/orchestrator.py +68 -209
  253. massgen/v1/streaming_display.py +62 -163
  254. massgen/v1/tools.py +8 -12
  255. massgen/v1/types.py +9 -23
  256. massgen/v1/utils.py +5 -23
  257. massgen-0.1.0.dist-info/METADATA +1245 -0
  258. massgen-0.1.0.dist-info/RECORD +273 -0
  259. massgen-0.1.0.dist-info/entry_points.txt +2 -0
  260. massgen/frontend/logging/__init__.py +0 -9
  261. massgen/frontend/logging/realtime_logger.py +0 -197
  262. massgen-0.0.3.dist-info/METADATA +0 -568
  263. massgen-0.0.3.dist-info/RECORD +0 -76
  264. massgen-0.0.3.dist-info/entry_points.txt +0 -2
  265. /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
  266. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
  267. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
  268. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,2087 @@
1
+ # -*- coding: utf-8 -*-
2
+ import asyncio
3
+ import json
4
+ import os
5
+ import shutil
6
+ import sys
7
+ import tempfile
8
+ import traceback
9
+ from pathlib import Path
10
+
11
+ # Removed wc_server import - now using factory function approach
12
+ from massgen.filesystem_manager import (
13
+ FileOperationTracker,
14
+ FilesystemManager,
15
+ PathPermissionManager,
16
+ Permission,
17
+ )
18
+ from massgen.filesystem_manager._workspace_tools_server import (
19
+ _validate_and_resolve_paths,
20
+ _validate_path_access,
21
+ get_copy_file_pairs,
22
+ )
23
+ from massgen.mcp_tools.client import MCPClient
24
+
25
+
26
+ class TestHelper:
27
+ def __init__(self):
28
+ self.temp_dir = None
29
+ self.workspace_dir = None
30
+ self.context_dir = None
31
+ self.readonly_dir = None
32
+
33
+ def setup(self):
34
+ self.temp_dir = Path(tempfile.mkdtemp())
35
+ self.workspace_dir = self.temp_dir / "workspace"
36
+ self.context_dir = self.temp_dir / "context"
37
+ self.readonly_dir = self.temp_dir / "readonly"
38
+
39
+ self.workspace_dir.mkdir(parents=True)
40
+ self.context_dir.mkdir(parents=True)
41
+ self.readonly_dir.mkdir(parents=True)
42
+ (self.workspace_dir / "workspace_file.txt").write_text("workspace content")
43
+ (self.context_dir / "context_file.txt").write_text("context content")
44
+ (self.readonly_dir / "readonly_file.txt").write_text("readonly content")
45
+
46
+ def teardown(self):
47
+ if self.temp_dir and self.temp_dir.exists():
48
+ shutil.rmtree(self.temp_dir)
49
+
50
+ def create_permission_manager(self, context_write_enabled=False):
51
+ manager = PathPermissionManager(context_write_access_enabled=context_write_enabled)
52
+ manager.add_path(self.workspace_dir, Permission.WRITE, "workspace")
53
+ if context_write_enabled:
54
+ manager.add_path(self.context_dir, Permission.WRITE, "context")
55
+ else:
56
+ manager.add_path(self.context_dir, Permission.READ, "context")
57
+ manager.add_path(self.readonly_dir, Permission.READ, "context")
58
+ return manager
59
+
60
+
61
+ async def test_mcp_relative_paths():
62
+ """Test that MCP servers resolve relative paths correctly when cwd is set."""
63
+ print("🧪 Testing MCP relative path resolution with cwd parameter...")
64
+
65
+ # Create temporary directories
66
+ with tempfile.TemporaryDirectory() as temp_dir:
67
+ temp_path = Path(temp_dir)
68
+ workspace_dir = temp_path / "workspace1"
69
+ workspace_dir.mkdir()
70
+
71
+ print(f"📁 Created test workspace: {workspace_dir}")
72
+
73
+ # Create filesystem manager (this should generate configs with cwd)
74
+ temp_workspace_parent = temp_path / "temp_workspaces"
75
+ temp_workspace_parent.mkdir()
76
+
77
+ filesystem_manager = FilesystemManager(
78
+ cwd=str(workspace_dir),
79
+ context_paths=[],
80
+ context_write_access_enabled=True,
81
+ agent_temporary_workspace_parent=str(temp_workspace_parent),
82
+ )
83
+
84
+ # Get MCP filesystem config - should include cwd parameter
85
+ filesystem_config = filesystem_manager.get_mcp_filesystem_config()
86
+ print(f"🔧 Filesystem MCP config: {filesystem_config}")
87
+
88
+ # Verify cwd is set correctly (resolve both paths to handle /private prefix on macOS)
89
+ expected_cwd = str(workspace_dir.resolve())
90
+ actual_cwd = str(Path(filesystem_config.get("cwd")).resolve())
91
+ assert actual_cwd == expected_cwd, f"Expected cwd={expected_cwd}, got {actual_cwd}"
92
+ print("✅ Filesystem config has correct cwd")
93
+
94
+ # Get workspace tools config - should also include cwd parameter
95
+ workspace_tools_config = filesystem_manager.get_workspace_tools_mcp_config()
96
+ print(f"🔧 Workspace tools MCP config: {workspace_tools_config}")
97
+
98
+ # Verify cwd is set correctly (resolve both paths to handle /private prefix on macOS)
99
+ expected_cwd = str(workspace_dir.resolve())
100
+ actual_cwd = str(Path(workspace_tools_config.get("cwd")).resolve())
101
+ assert actual_cwd == expected_cwd, f"Expected cwd={expected_cwd}, got {actual_cwd}"
102
+ print("✅ Workspace tools config has correct cwd")
103
+
104
+ # Test filesystem MCP server
105
+ print("\n📡 Testing filesystem MCP server...")
106
+ try:
107
+ async with MCPClient([filesystem_config], timeout_seconds=10) as client:
108
+ print("✅ Filesystem MCP server connected successfully")
109
+ tools = client.get_available_tools()
110
+ print(f"🔧 Available tools: {tools}")
111
+
112
+ # Test creating a directory with relative path
113
+ if "create_directory" in tools:
114
+ print("🏗️ Testing create_directory with relative path 'api'...")
115
+ try:
116
+ result = await client.call_tool("create_directory", {"path": "api"})
117
+ print(f"✅ create_directory result: {result}")
118
+
119
+ # Verify directory was created in workspace
120
+ api_dir = workspace_dir / "api"
121
+ if api_dir.exists():
122
+ print(f"✅ Directory created at correct location: {api_dir}")
123
+ else:
124
+ print(f"❌ Directory not found at expected location: {api_dir}")
125
+
126
+ except Exception as e:
127
+ print(f"⚠️ create_directory failed: {e}")
128
+ else:
129
+ print("⚠️ create_directory tool not available")
130
+
131
+ except Exception as e:
132
+ print(f"❌ Filesystem MCP server test failed: {e}")
133
+
134
+ # Test workspace tools MCP server
135
+ print("\n📦 Testing workspace tools MCP server...")
136
+ try:
137
+ async with MCPClient([workspace_tools_config], timeout_seconds=10) as client:
138
+ print("✅ Workspace tools MCP server connected successfully")
139
+ tools = client.get_available_tools()
140
+ print(f"🔧 Available tools: {tools}")
141
+
142
+ # Test get_cwd to verify working directory
143
+ if "get_cwd" in tools:
144
+ print("📍 Testing get_cwd to verify working directory...")
145
+ try:
146
+ cwd_result = await client.call_tool("get_cwd", {})
147
+ print(f"✅ get_cwd result: {cwd_result}")
148
+
149
+ # Extract cwd info from structured content if available
150
+ if hasattr(cwd_result, "structuredContent") and cwd_result.structuredContent:
151
+ cwd_info = cwd_result.structuredContent
152
+ else:
153
+ # Fallback to parsing text content
154
+ cwd_info = json.loads(cwd_result.content[0].text)
155
+
156
+ server_cwd = cwd_info.get("cwd")
157
+ expected_cwd = str(workspace_dir.resolve())
158
+ actual_cwd = str(Path(server_cwd).resolve())
159
+
160
+ if actual_cwd == expected_cwd:
161
+ print(f"✅ Server is running in correct directory: {server_cwd}")
162
+ else:
163
+ print(f"❌ Server working directory mismatch: expected {expected_cwd}, got {actual_cwd}")
164
+
165
+ except Exception as e:
166
+ print(f"⚠️ get_cwd failed: {e}")
167
+ else:
168
+ print("⚠️ get_cwd tool not available")
169
+
170
+ # Create a test source file in the temp workspace (which is in allowed paths)
171
+ source_dir = temp_workspace_parent / "source"
172
+ source_dir.mkdir()
173
+ test_file = source_dir / "test.txt"
174
+ test_file.write_text("test content")
175
+
176
+ # Test copying with relative destination path
177
+ if "copy_file" in tools:
178
+ print("📋 Testing copy_file with relative destination path...")
179
+ try:
180
+ result = await client.call_tool(
181
+ "copy_file",
182
+ {
183
+ "source_path": str(test_file),
184
+ "destination_path": "copied_file.txt", # Relative path
185
+ },
186
+ )
187
+ print(f"✅ copy_file result: {result}")
188
+
189
+ # Verify file was copied to workspace
190
+ copied_file = workspace_dir / "copied_file.txt"
191
+ if copied_file.exists():
192
+ print(f"✅ File copied to correct location: {copied_file}")
193
+ content = copied_file.read_text()
194
+ if content == "test content":
195
+ print("✅ File content is correct")
196
+ else:
197
+ print(f"❌ File content mismatch: {content}")
198
+ else:
199
+ print(f"❌ File not found at expected location: {copied_file}")
200
+
201
+ except Exception as e:
202
+ print(f"⚠️ copy_file failed: {e}")
203
+ else:
204
+ print("⚠️ copy_file tool not available")
205
+
206
+ except Exception as e:
207
+ print(f"❌ Workspace copy MCP server test failed: {e}")
208
+
209
+ print("\n🎉 MCP relative path testing complete!")
210
+
211
+
212
+ def test_is_write_tool():
213
+ print("\n📝 Testing _is_write_tool method...")
214
+
215
+ helper = TestHelper()
216
+ helper.setup()
217
+
218
+ try:
219
+ manager = helper.create_permission_manager()
220
+ claude_write_tools = ["Write", "Edit", "MultiEdit", "NotebookEdit"]
221
+ for tool in claude_write_tools:
222
+ if not manager._is_write_tool(tool):
223
+ print(f"❌ Failed: {tool} should be detected as write tool")
224
+ return False
225
+ claude_read_tools = ["Read", "Glob", "Grep", "WebFetch"]
226
+ for tool in claude_read_tools:
227
+ if manager._is_write_tool(tool):
228
+ print(f"❌ Failed: {tool} should NOT be detected as write tool")
229
+ return False
230
+ mcp_write_tools = ["write_file", "edit_file", "create_directory", "move_file", "delete_file", "remove_directory"]
231
+ for tool in mcp_write_tools:
232
+ if not manager._is_write_tool(tool):
233
+ print(f"❌ Failed: {tool} should be detected as write tool")
234
+ return False
235
+ mcp_read_tools = ["read_file", "list_directory"]
236
+ for tool in mcp_read_tools:
237
+ if manager._is_write_tool(tool):
238
+ print(f"❌ Failed: {tool} should NOT be detected as write tool")
239
+ return False
240
+
241
+ print("✅ _is_write_tool detection works correctly")
242
+ return True
243
+
244
+ finally:
245
+ helper.teardown()
246
+
247
+
248
+ def test_validate_write_tool():
249
+ print("\n📝 Testing _validate_write_tool method...")
250
+
251
+ helper = TestHelper()
252
+ helper.setup()
253
+
254
+ try:
255
+ print(" Testing workspace write access...")
256
+ manager = helper.create_permission_manager(context_write_enabled=False)
257
+ tool_args = {"file_path": str(helper.workspace_dir / "workspace_file.txt")}
258
+ allowed, reason = manager._validate_write_tool("Write", tool_args)
259
+
260
+ if not allowed:
261
+ print(f"❌ Failed: Workspace should always be writable. Reason: {reason}")
262
+ return False
263
+ print(" Testing context path with write enabled...")
264
+ manager = helper.create_permission_manager(context_write_enabled=True)
265
+ tool_args = {"file_path": str(helper.context_dir / "context_file.txt")}
266
+ allowed, reason = manager._validate_write_tool("Write", tool_args)
267
+
268
+ if not allowed:
269
+ print(f"❌ Failed: Context path should be writable when enabled. Reason: {reason}")
270
+ return False
271
+ print(" Testing context path with write disabled...")
272
+ manager = helper.create_permission_manager(context_write_enabled=False)
273
+ tool_args = {"file_path": str(helper.context_dir / "context_file.txt")}
274
+ allowed, reason = manager._validate_write_tool("Write", tool_args)
275
+
276
+ if allowed:
277
+ print("❌ Failed: Context path should NOT be writable when disabled")
278
+ return False
279
+ if "read-only context path" not in reason:
280
+ print(f"❌ Failed: Expected 'read-only context path' in reason, got: {reason}")
281
+ return False
282
+ print(" Testing readonly path...")
283
+ for context_write_enabled in [True, False]:
284
+ manager = helper.create_permission_manager(context_write_enabled=context_write_enabled)
285
+ tool_args = {"file_path": str(helper.readonly_dir / "readonly_file.txt")}
286
+ allowed, reason = manager._validate_write_tool("Write", tool_args)
287
+
288
+ if allowed:
289
+ print(f"❌ Failed: Readonly path should never be writable (context_write={context_write_enabled})")
290
+ return False
291
+ print(" Testing unknown path...")
292
+ manager = helper.create_permission_manager()
293
+ unknown_file = helper.temp_dir / "unknown" / "file.txt"
294
+ unknown_file.parent.mkdir(exist_ok=True)
295
+ unknown_file.write_text("content")
296
+
297
+ tool_args = {"file_path": str(unknown_file)}
298
+ allowed, reason = manager._validate_write_tool("Write", tool_args)
299
+
300
+ if not allowed:
301
+ print(f"❌ Failed: Unknown paths should be allowed. Reason: {reason}")
302
+ return False
303
+ print(" Testing different path argument names...")
304
+ manager = helper.create_permission_manager(context_write_enabled=False)
305
+ readonly_file = str(helper.readonly_dir / "readonly_file.txt")
306
+
307
+ path_arg_names = ["file_path", "path", "filename", "notebook_path", "target"]
308
+ for arg_name in path_arg_names:
309
+ tool_args = {arg_name: readonly_file}
310
+ allowed, reason = manager._validate_write_tool("Write", tool_args)
311
+
312
+ if allowed:
313
+ print(f"❌ Failed: Should block readonly with arg name '{arg_name}'")
314
+ return False
315
+
316
+ print("✅ _validate_write_tool works correctly")
317
+ return True
318
+
319
+ finally:
320
+ helper.teardown()
321
+
322
+
323
+ def test_validate_command_tool():
324
+ print("\n🔧 Testing _validate_command_tool method...")
325
+
326
+ helper = TestHelper()
327
+ helper.setup()
328
+
329
+ try:
330
+ manager = helper.create_permission_manager()
331
+ print(" Testing dangerous command blocking...")
332
+ dangerous_commands = [
333
+ "rm file.txt",
334
+ "rm -rf directory/",
335
+ "sudo apt install",
336
+ "su root",
337
+ "chmod 777 file.txt",
338
+ "chown user:group file.txt",
339
+ "format C:",
340
+ "fdisk /dev/sda",
341
+ "mkfs.ext4 /dev/sdb1",
342
+ ]
343
+
344
+ for cmd in dangerous_commands:
345
+ tool_args = {"command": cmd}
346
+ allowed, reason = manager._validate_command_tool("Bash", tool_args)
347
+
348
+ if allowed:
349
+ print(f"❌ Failed: Dangerous command should be blocked: {cmd}")
350
+ return False
351
+ if "Dangerous command pattern" not in reason:
352
+ print(f"❌ Failed: Expected 'Dangerous command pattern' for: {cmd}, got: {reason}")
353
+ return False
354
+ print(" Testing safe command allowance...")
355
+ safe_commands = ["ls -la", "cat file.txt", "grep pattern file.txt", "find . -name '*.py'", "python script.py", "npm install", "git status"]
356
+
357
+ for cmd in safe_commands:
358
+ tool_args = {"command": cmd}
359
+ allowed, reason = manager._validate_command_tool("Bash", tool_args)
360
+
361
+ if not allowed:
362
+ print(f"❌ Failed: Safe command should be allowed: {cmd}. Reason: {reason}")
363
+ return False
364
+ print(" Testing write operations to readonly paths...")
365
+ manager = helper.create_permission_manager(context_write_enabled=False)
366
+ readonly_file = str(helper.readonly_dir / "readonly_file.txt")
367
+
368
+ write_commands = [
369
+ f"echo 'content' > {readonly_file}",
370
+ f"echo 'content' >> {readonly_file}",
371
+ f"mv source.txt {readonly_file}",
372
+ f"cp source.txt {readonly_file}",
373
+ f"touch {readonly_file}",
374
+ ]
375
+
376
+ for cmd in write_commands:
377
+ tool_args = {"command": cmd}
378
+ allowed, reason = manager._validate_command_tool("Bash", tool_args)
379
+
380
+ if allowed:
381
+ print(f"❌ Failed: Write to readonly should be blocked: {cmd}")
382
+ return False
383
+ if "read-only context path" not in reason:
384
+ print(f"❌ Failed: Expected 'read-only context path' for: {cmd}, got: {reason}")
385
+ return False
386
+ print(" Testing write operations to workspace...")
387
+ workspace_file = str(helper.workspace_dir / "workspace_file.txt")
388
+
389
+ write_commands = [
390
+ f"echo 'content' > {workspace_file}",
391
+ f"echo 'content' >> {workspace_file}",
392
+ f"mv source.txt {workspace_file}",
393
+ f"cp source.txt {workspace_file}",
394
+ ]
395
+
396
+ for cmd in write_commands:
397
+ tool_args = {"command": cmd}
398
+ allowed, reason = manager._validate_command_tool("Bash", tool_args)
399
+
400
+ if not allowed:
401
+ print(f"❌ Failed: Write to workspace should be allowed: {cmd}. Reason: {reason}")
402
+ return False
403
+
404
+ print("✅ _validate_command_tool works correctly")
405
+ return True
406
+
407
+ finally:
408
+ helper.teardown()
409
+
410
+
411
+ def test_validate_execute_command_tool():
412
+ print("\n⚙️ Testing _validate_command_tool for execute_command...")
413
+
414
+ helper = TestHelper()
415
+ helper.setup()
416
+
417
+ try:
418
+ manager = helper.create_permission_manager()
419
+ print(" Testing dangerous command blocking for execute_command...")
420
+ dangerous_commands = [
421
+ "rm file.txt",
422
+ "rm -rf directory/",
423
+ "sudo apt install",
424
+ "su root",
425
+ "chmod 777 file.txt",
426
+ "chown user:group file.txt",
427
+ "format C:",
428
+ "fdisk /dev/sda",
429
+ "mkfs.ext4 /dev/sdb1",
430
+ ]
431
+
432
+ for cmd in dangerous_commands:
433
+ tool_args = {"command": cmd}
434
+ allowed, reason = manager._validate_command_tool("execute_command", tool_args)
435
+
436
+ if allowed:
437
+ print(f"❌ Failed: Dangerous command should be blocked for execute_command: {cmd}")
438
+ return False
439
+ if "Dangerous command pattern" not in reason:
440
+ print(f"❌ Failed: Expected 'Dangerous command pattern' for: {cmd}, got: {reason}")
441
+ return False
442
+ print(" Testing safe command allowance for execute_command...")
443
+ safe_commands = [
444
+ "python script.py",
445
+ "pytest tests/",
446
+ "npm run build",
447
+ "ls -la",
448
+ "cat file.txt",
449
+ "git status",
450
+ "node app.js",
451
+ ]
452
+
453
+ for cmd in safe_commands:
454
+ tool_args = {"command": cmd}
455
+ allowed, reason = manager._validate_command_tool("execute_command", tool_args)
456
+
457
+ if not allowed:
458
+ print(f"❌ Failed: Safe command should be allowed for execute_command: {cmd}. Reason: {reason}")
459
+ return False
460
+ print(" Testing write operations to readonly paths for execute_command...")
461
+ manager = helper.create_permission_manager(context_write_enabled=False)
462
+ readonly_file = str(helper.readonly_dir / "readonly_file.txt")
463
+
464
+ write_commands = [
465
+ f"echo 'content' > {readonly_file}",
466
+ f"echo 'content' >> {readonly_file}",
467
+ f"mv source.txt {readonly_file}",
468
+ f"cp source.txt {readonly_file}",
469
+ f"touch {readonly_file}",
470
+ ]
471
+
472
+ for cmd in write_commands:
473
+ tool_args = {"command": cmd}
474
+ allowed, reason = manager._validate_command_tool("execute_command", tool_args)
475
+
476
+ if allowed:
477
+ print(f"❌ Failed: Write to readonly should be blocked for execute_command: {cmd}")
478
+ return False
479
+ if "read-only context path" not in reason:
480
+ print(f"❌ Failed: Expected 'read-only context path' for: {cmd}, got: {reason}")
481
+ return False
482
+ print(" Testing write operations to workspace for execute_command...")
483
+ workspace_file = str(helper.workspace_dir / "workspace_file.txt")
484
+
485
+ write_commands = [
486
+ f"echo 'content' > {workspace_file}",
487
+ f"echo 'content' >> {workspace_file}",
488
+ f"mv source.txt {workspace_file}",
489
+ f"cp source.txt {workspace_file}",
490
+ ]
491
+
492
+ for cmd in write_commands:
493
+ tool_args = {"command": cmd}
494
+ allowed, reason = manager._validate_command_tool("execute_command", tool_args)
495
+
496
+ if not allowed:
497
+ print(f"❌ Failed: Write to workspace should be allowed for execute_command: {cmd}. Reason: {reason}")
498
+ return False
499
+
500
+ print(" Testing write operations to paths outside all managed directories...")
501
+ # Create a directory outside workspace, context, and readonly dirs
502
+ outside_dir = helper.temp_dir / "completely_outside"
503
+ outside_dir.mkdir(parents=True)
504
+ outside_file = str(outside_dir / "outside_file.txt")
505
+
506
+ # Commands writing to completely unmanaged paths
507
+ # These should be allowed since they're not in any context path
508
+ # (manager only restricts writes to read-only context paths)
509
+ outside_commands = [
510
+ f"echo 'content' > {outside_file}",
511
+ f"cp source.txt {outside_file}",
512
+ ]
513
+
514
+ for cmd in outside_commands:
515
+ tool_args = {"command": cmd}
516
+ allowed, reason = manager._validate_command_tool("execute_command", tool_args)
517
+
518
+ if not allowed:
519
+ print(f"❌ Failed: Write to unmanaged path should be allowed for execute_command: {cmd}. Reason: {reason}")
520
+ return False
521
+
522
+ print("✅ _validate_command_tool works correctly for execute_command")
523
+ return True
524
+
525
+ finally:
526
+ helper.teardown()
527
+
528
+
529
+ async def test_pre_tool_use_hook():
530
+ print("\n🪝 Testing pre_tool_use_hook method...")
531
+
532
+ helper = TestHelper()
533
+ helper.setup()
534
+
535
+ try:
536
+ print(" Testing write tool on readonly path...")
537
+ manager = helper.create_permission_manager(context_write_enabled=False)
538
+ tool_args = {"file_path": str(helper.readonly_dir / "readonly_file.txt")}
539
+ allowed, reason = await manager.pre_tool_use_hook("Write", tool_args)
540
+
541
+ if allowed:
542
+ print("❌ Failed: Write tool on readonly path should be blocked")
543
+ return False
544
+ if "read-only context path" not in reason:
545
+ print(f"❌ Failed: Expected 'read-only context path' in reason, got: {reason}")
546
+ return False
547
+ print(" Testing dangerous command with Bash...")
548
+ tool_args = {"command": "rm -rf /"}
549
+ allowed, reason = await manager.pre_tool_use_hook("Bash", tool_args)
550
+
551
+ if allowed:
552
+ print("❌ Failed: Dangerous command should be blocked for Bash")
553
+ return False
554
+ if "Dangerous command pattern" not in reason:
555
+ print(f"❌ Failed: Expected 'Dangerous command pattern' in reason, got: {reason}")
556
+ return False
557
+ print(" Testing dangerous command with execute_command...")
558
+ tool_args = {"command": "sudo apt install malware"}
559
+ allowed, reason = await manager.pre_tool_use_hook("execute_command", tool_args)
560
+
561
+ if allowed:
562
+ print("❌ Failed: Dangerous command should be blocked for execute_command")
563
+ return False
564
+ if "Dangerous command pattern" not in reason:
565
+ print(f"❌ Failed: Expected 'Dangerous command pattern' in reason for execute_command, got: {reason}")
566
+ return False
567
+ print(" Testing safe command with execute_command...")
568
+ tool_args = {"command": "python test.py"}
569
+ allowed, reason = await manager.pre_tool_use_hook("execute_command", tool_args)
570
+
571
+ if not allowed:
572
+ print(f"❌ Failed: Safe command should be allowed for execute_command. Reason: {reason}")
573
+ return False
574
+ print(" Testing write to readonly with execute_command...")
575
+ readonly_file = str(helper.readonly_dir / "readonly_file.txt")
576
+ tool_args = {"command": f"echo 'data' > {readonly_file}"}
577
+ allowed, reason = await manager.pre_tool_use_hook("execute_command", tool_args)
578
+
579
+ if allowed:
580
+ print("❌ Failed: Write to readonly should be blocked for execute_command")
581
+ return False
582
+ if "read-only context path" not in reason:
583
+ print(f"❌ Failed: Expected 'read-only context path' in reason for execute_command, got: {reason}")
584
+ return False
585
+ print(" Testing read tools...")
586
+ read_tools = ["Read", "Glob", "Grep", "WebFetch", "WebSearch"]
587
+
588
+ for tool_name in read_tools:
589
+ tool_args = {"file_path": str(helper.readonly_dir / "readonly_file.txt")}
590
+ allowed, reason = await manager.pre_tool_use_hook(tool_name, tool_args)
591
+
592
+ if not allowed:
593
+ print(f"❌ Failed: Read tool should always be allowed: {tool_name}. Reason: {reason}")
594
+ return False
595
+ print(" Testing unknown tools...")
596
+ tool_args = {"some_param": "value"}
597
+ allowed, reason = await manager.pre_tool_use_hook("CustomTool", tool_args)
598
+
599
+ if not allowed:
600
+ print(f"❌ Failed: Unknown tool should be allowed. Reason: {reason}")
601
+ return False
602
+
603
+ print("✅ pre_tool_use_hook works correctly")
604
+ return True
605
+
606
+ finally:
607
+ helper.teardown()
608
+
609
+
610
+ def test_context_write_access_toggle():
611
+ print("\n🔄 Testing context write access toggle...")
612
+
613
+ helper = TestHelper()
614
+ helper.setup()
615
+
616
+ try:
617
+ manager = PathPermissionManager(context_write_access_enabled=False)
618
+ context_paths = [{"path": str(helper.context_dir), "permission": "write"}, {"path": str(helper.readonly_dir), "permission": "read"}]
619
+ manager.add_context_paths(context_paths)
620
+ print(" Testing initial read-only state...")
621
+ if manager.get_permission(helper.context_dir) != Permission.READ:
622
+ print("❌ Failed: Context path should initially be read-only")
623
+ return False
624
+ if manager.get_permission(helper.readonly_dir) != Permission.READ:
625
+ print("❌ Failed: Readonly path should be read-only")
626
+ return False
627
+ print(" Testing write access enabled...")
628
+ manager.set_context_write_access_enabled(True)
629
+
630
+ if manager.get_permission(helper.context_dir) != Permission.WRITE:
631
+ print("❌ Failed: Context path should be writable after enabling")
632
+ return False
633
+ if manager.get_permission(helper.readonly_dir) != Permission.READ:
634
+ print("❌ Failed: Readonly path should stay read-only")
635
+ return False
636
+ print(" Testing write access disabled again...")
637
+ manager.set_context_write_access_enabled(False)
638
+
639
+ if manager.get_permission(helper.context_dir) != Permission.READ:
640
+ print("❌ Failed: Context path should be read-only after disabling")
641
+ return False
642
+ if manager.get_permission(helper.readonly_dir) != Permission.READ:
643
+ print("❌ Failed: Readonly path should stay read-only")
644
+ return False
645
+
646
+ print("✅ Context write access toggle works correctly")
647
+ return True
648
+
649
+ finally:
650
+ helper.teardown()
651
+
652
+
653
+ def test_extract_file_from_command():
654
+ print("\n📄 Testing _extract_file_from_command method...")
655
+
656
+ helper = TestHelper()
657
+ helper.setup()
658
+
659
+ try:
660
+ manager = helper.create_permission_manager()
661
+ print(" Testing redirect command extraction...")
662
+ test_cases = [
663
+ ("echo 'content' > file.txt", ">", "file.txt"),
664
+ ("cat input.txt >> output.log", ">>", "output.log"),
665
+ ("ls -la > /path/to/file.txt", ">", "/path/to/file.txt"),
666
+ ]
667
+
668
+ for command, pattern, expected in test_cases:
669
+ result = manager._extract_file_from_command(command, pattern)
670
+ if result != expected:
671
+ print(f"❌ Failed: Expected '{expected}' from '{command}', got '{result}'")
672
+ return False
673
+ print(" Testing move/copy command extraction...")
674
+ test_cases = [
675
+ ("mv source.txt dest.txt", "mv ", "dest.txt"),
676
+ ("cp file1.txt file2.txt", "cp ", "file2.txt"),
677
+ ("move old.txt new.txt", "move ", "new.txt"),
678
+ ("copy source.doc target.doc", "copy ", "target.doc"),
679
+ ]
680
+
681
+ for command, pattern, expected in test_cases:
682
+ result = manager._extract_file_from_command(command, pattern)
683
+ if result != expected:
684
+ print(f"❌ Failed: Expected '{expected}' from '{command}', got '{result}'")
685
+ return False
686
+
687
+ print("✅ _extract_file_from_command works correctly")
688
+ return True
689
+
690
+ finally:
691
+ helper.teardown()
692
+
693
+
694
+ def test_workspace_tools():
695
+ print("\n📦 Testing workspace tools validation...")
696
+
697
+ helper = TestHelper()
698
+ helper.setup()
699
+
700
+ try:
701
+ temp_workspace_dir = helper.temp_dir / "temp_workspace"
702
+ temp_workspace_dir.mkdir(parents=True)
703
+ (temp_workspace_dir / "source_file.txt").write_text("source content")
704
+ print(" Testing copy tool detection...")
705
+ manager = helper.create_permission_manager(context_write_enabled=False)
706
+ # Add temp_workspace_dir to the permission manager's allowed paths
707
+ manager.add_path(temp_workspace_dir, Permission.READ, "temp_workspace")
708
+
709
+ copy_tools = ["copy_file", "copy_files_batch", "mcp__workspace_tools__copy_file", "mcp__workspace_tools__copy_files_batch"]
710
+ for tool in copy_tools:
711
+ if not manager._is_write_tool(tool):
712
+ print(f"❌ Failed: {tool} should be detected as write tool")
713
+ return False
714
+ print(" Testing copy_file destination permissions...")
715
+ tool_args = {"source_path": str(temp_workspace_dir / "source_file.txt"), "destination_path": str(helper.workspace_dir / "dest_file.txt")}
716
+ allowed, reason = manager._validate_write_tool("copy_file", tool_args)
717
+ if not allowed:
718
+ print(f"❌ Failed: copy_file to workspace should be allowed. Reason: {reason}")
719
+ return False
720
+ tool_args = {"source_path": str(temp_workspace_dir / "source_file.txt"), "destination_path": str(helper.readonly_dir / "dest_file.txt")}
721
+ allowed, reason = manager._validate_write_tool("copy_file", tool_args)
722
+ if allowed:
723
+ print("❌ Failed: copy_file to readonly directory should be blocked")
724
+ return False
725
+ print(" Testing copy FROM read-only paths...")
726
+ tool_args = {
727
+ "source_path": str(helper.readonly_dir / "readonly_file.txt"),
728
+ "destination_path": str(helper.workspace_dir / "copied_from_readonly.txt"),
729
+ }
730
+ allowed, reason = manager._validate_write_tool("copy_file", tool_args)
731
+ if not allowed:
732
+ print(f"❌ Failed: copy FROM read-only path should be allowed. Reason: {reason}")
733
+ return False
734
+ tool_args = {"source_base_path": str(helper.readonly_dir), "destination_base_path": str(helper.workspace_dir / "copied_from_readonly")}
735
+ allowed, reason = manager._validate_write_tool("copy_files_batch", tool_args)
736
+ if not allowed:
737
+ print(f"❌ Failed: copy_files_batch FROM read-only path should be allowed. Reason: {reason}")
738
+ return False
739
+ print(" Testing copy_files_batch destination permissions...")
740
+ tool_args = {"source_base_path": str(temp_workspace_dir), "destination_base_path": str(helper.workspace_dir / "output")}
741
+ allowed, reason = manager._validate_write_tool("copy_files_batch", tool_args)
742
+ if not allowed:
743
+ print(f"❌ Failed: copy_files_batch to workspace subdirectory should be allowed. Reason: {reason}")
744
+ return False
745
+ tool_args = {"source_base_path": str(temp_workspace_dir), "destination_base_path": str(helper.readonly_dir / "output")}
746
+ allowed, reason = manager._validate_write_tool("copy_files_batch", tool_args)
747
+ if allowed:
748
+ print("❌ Failed: copy_files_batch to readonly directory should be blocked")
749
+ return False
750
+ print(" Testing _extract_file_path with copy arguments...")
751
+ tool_args = {"source_path": str(temp_workspace_dir / "source.txt"), "destination_path": str(helper.workspace_dir / "dest.txt")}
752
+ extracted = manager._extract_file_path(tool_args)
753
+ if extracted != str(helper.workspace_dir / "dest.txt"):
754
+ print(f"❌ Failed: Should extract destination_path, got: {extracted}")
755
+ return False
756
+ tool_args = {"source_base_path": str(temp_workspace_dir), "destination_base_path": str(helper.workspace_dir / "output")}
757
+ extracted = manager._extract_file_path(tool_args)
758
+ if extracted != str(helper.workspace_dir / "output"):
759
+ print(f"❌ Failed: Should extract destination_base_path, got: {extracted}")
760
+ return False
761
+ print(" Testing absolute path validation...")
762
+ tool_args = {"source_path": str(temp_workspace_dir / "source_file.txt"), "destination_path": str(helper.workspace_dir / "valid_destination.txt")}
763
+ allowed, reason = manager._validate_write_tool("copy_file", tool_args)
764
+ if not allowed:
765
+ print(f"❌ Failed: copy_file with valid absolute destination should be allowed. Reason: {reason}")
766
+ return False
767
+ tool_args = {"source_base_path": str(temp_workspace_dir), "destination_base_path": str(helper.workspace_dir / "batch_output")}
768
+ allowed, reason = manager._validate_write_tool("copy_files_batch", tool_args)
769
+ if not allowed:
770
+ print(f"❌ Failed: copy_files_batch with valid absolute destination should be allowed. Reason: {reason}")
771
+ return False
772
+ print(" Testing outside allowed paths...")
773
+ outside_dir = helper.temp_dir / "outside_allowed"
774
+ outside_dir.mkdir(parents=True)
775
+ tool_args = {"source_path": str(temp_workspace_dir / "source_file.txt"), "destination_path": str(outside_dir / "should_be_blocked.txt")}
776
+ allowed, reason = manager._validate_write_tool("copy_file", tool_args)
777
+ print("✅ Workspace copy tool validation works correctly")
778
+ return True
779
+
780
+ finally:
781
+ helper.teardown()
782
+
783
+
784
+ def test_default_exclusions():
785
+ print("\n🚫 Testing default system file exclusions...")
786
+
787
+ helper = TestHelper()
788
+ helper.setup()
789
+
790
+ try:
791
+ manager = helper.create_permission_manager(context_write_enabled=True)
792
+
793
+ # Add context path with write permission
794
+ project_dir = helper.temp_dir / "project"
795
+ project_dir.mkdir()
796
+ manager.add_path(project_dir, Permission.WRITE, "context")
797
+
798
+ print(" Testing excluded patterns are blocked...")
799
+ excluded_files = [
800
+ project_dir / ".env",
801
+ project_dir / ".git" / "config",
802
+ project_dir / "node_modules" / "package" / "index.js",
803
+ project_dir / "__pycache__" / "module.pyc",
804
+ project_dir / ".venv" / "lib" / "python.py",
805
+ project_dir / ".massgen" / "sessions" / "session.json",
806
+ project_dir / "massgen_logs" / "app.log",
807
+ ]
808
+
809
+ for excluded_file in excluded_files:
810
+ excluded_file.parent.mkdir(parents=True, exist_ok=True)
811
+ excluded_file.write_text("content")
812
+
813
+ permission = manager.get_permission(excluded_file)
814
+ if permission != Permission.READ:
815
+ print(f"❌ Failed: {excluded_file} should be READ, got {permission}")
816
+ return False
817
+
818
+ print(" Testing normal files are writable...")
819
+ normal_files = [
820
+ project_dir / "src" / "main.py",
821
+ project_dir / "README.md",
822
+ project_dir / "config.yaml",
823
+ ]
824
+
825
+ for normal_file in normal_files:
826
+ normal_file.parent.mkdir(parents=True, exist_ok=True)
827
+ normal_file.write_text("content")
828
+
829
+ permission = manager.get_permission(normal_file)
830
+ if permission != Permission.WRITE:
831
+ print(f"❌ Failed: {normal_file} should be WRITE, got {permission}")
832
+ return False
833
+
834
+ print(" Testing workspace overrides exclusions...")
835
+ workspace_dir = helper.temp_dir / "project" / ".massgen" / "workspaces" / "workspace1"
836
+ workspace_dir.mkdir(parents=True)
837
+ manager.add_path(workspace_dir, Permission.WRITE, "workspace")
838
+
839
+ workspace_file = workspace_dir / "index.html"
840
+ workspace_file.write_text("content")
841
+
842
+ permission = manager.get_permission(workspace_file)
843
+ if permission != Permission.WRITE:
844
+ print(f"❌ Failed: Workspace file should be WRITE even under .massgen/, got {permission}")
845
+ return False
846
+
847
+ print("✅ Default system file exclusions work correctly")
848
+ return True
849
+
850
+ finally:
851
+ helper.teardown()
852
+
853
+
854
+ def test_path_priority_resolution():
855
+ print("\n🎯 Testing path priority resolution (depth-first)...")
856
+
857
+ helper = TestHelper()
858
+ helper.setup()
859
+
860
+ try:
861
+ manager = PathPermissionManager(context_write_access_enabled=True)
862
+
863
+ # Add a broad parent context path (read-only)
864
+ project_dir = helper.temp_dir / "project"
865
+ project_dir.mkdir()
866
+ manager.add_path(project_dir, Permission.READ, "context")
867
+
868
+ # Add a deeper workspace path (writable)
869
+ workspace_dir = project_dir / ".massgen" / "workspaces" / "workspace1"
870
+ workspace_dir.mkdir(parents=True)
871
+ manager.add_path(workspace_dir, Permission.WRITE, "workspace")
872
+
873
+ print(" Testing workspace file uses deeper path permission...")
874
+ workspace_file = workspace_dir / "index.html"
875
+ workspace_file.write_text("content")
876
+
877
+ permission = manager.get_permission(workspace_file)
878
+ if permission != Permission.WRITE:
879
+ print(f"❌ Failed: Workspace file should use workspace WRITE permission, got {permission}")
880
+ return False
881
+
882
+ print(" Testing project file uses parent path permission...")
883
+ project_file = project_dir / "README.md"
884
+ project_file.write_text("content")
885
+
886
+ permission = manager.get_permission(project_file)
887
+ if permission != Permission.READ:
888
+ print(f"❌ Failed: Project file should use context READ permission, got {permission}")
889
+ return False
890
+
891
+ print(" Testing multiple nested paths...")
892
+ # Add another level
893
+ nested_dir = project_dir / "src" / "components"
894
+ nested_dir.mkdir(parents=True)
895
+ manager.add_path(nested_dir, Permission.WRITE, "context")
896
+
897
+ nested_file = nested_dir / "Button.jsx"
898
+ nested_file.write_text("content")
899
+
900
+ permission = manager.get_permission(nested_file)
901
+ if permission != Permission.WRITE:
902
+ print(f"❌ Failed: Nested file should use deepest matching path, got {permission}")
903
+ return False
904
+
905
+ # File in src/ but not in components/
906
+ src_file = project_dir / "src" / "index.js"
907
+ src_file.write_text("content")
908
+
909
+ permission = manager.get_permission(src_file)
910
+ if permission != Permission.READ:
911
+ print(f"❌ Failed: src/ file should use parent context READ permission, got {permission}")
912
+ return False
913
+
914
+ print("✅ Path priority resolution works correctly")
915
+ return True
916
+
917
+ finally:
918
+ helper.teardown()
919
+
920
+
921
+ def test_workspace_tools_server_path_validation():
922
+ print("\n🏗️ Testing workspace tools server path validation...")
923
+
924
+ helper = TestHelper()
925
+ helper.setup()
926
+
927
+ try:
928
+ # Set up allowed paths for the new factory function approach
929
+ allowed_paths = [helper.workspace_dir.resolve(), helper.context_dir.resolve(), helper.readonly_dir.resolve()]
930
+
931
+ test_source_dir = helper.temp_dir / "source"
932
+ test_source_dir.mkdir()
933
+ (test_source_dir / "test_file.txt").write_text("test content")
934
+ (test_source_dir / "subdir" / "nested_file.txt").parent.mkdir(parents=True)
935
+ (test_source_dir / "subdir" / "nested_file.txt").write_text("nested content")
936
+ allowed_paths.append(test_source_dir.resolve())
937
+
938
+ print(" Testing valid absolute destination path...")
939
+ try:
940
+ dest_path = helper.workspace_dir / "output"
941
+ file_pairs = get_copy_file_pairs(allowed_paths, str(test_source_dir), str(dest_path))
942
+ if len(file_pairs) < 2:
943
+ print(f"❌ Failed: Expected at least 2 files, got {len(file_pairs)}")
944
+ return False
945
+ print(f" ✓ Found {len(file_pairs)} files to copy")
946
+ except Exception as e:
947
+ print(f"❌ Failed: Valid absolute path should work. Error: {e}")
948
+ return False
949
+ print(" Testing destination outside allowed paths...")
950
+ outside_dir = helper.temp_dir / "outside"
951
+ outside_dir.mkdir()
952
+
953
+ try:
954
+ file_pairs = get_copy_file_pairs(allowed_paths, str(test_source_dir), str(outside_dir / "output"))
955
+ print("❌ Failed: Should have raised ValueError for path outside allowed directories")
956
+ return False
957
+ except ValueError as e:
958
+ if "Path not in allowed directories" in str(e):
959
+ print(" ✓ Correctly blocked path outside allowed directories")
960
+ else:
961
+ print(f"❌ Failed: Unexpected error: {e}")
962
+ return False
963
+ except Exception as e:
964
+ print(f"❌ Failed: Unexpected exception: {e}")
965
+ return False
966
+ print(" Testing source outside allowed paths...")
967
+ outside_source = helper.temp_dir / "outside_source"
968
+ outside_source.mkdir()
969
+ (outside_source / "bad_file.txt").write_text("bad content")
970
+
971
+ try:
972
+ file_pairs = get_copy_file_pairs(allowed_paths, str(outside_source), str(helper.workspace_dir / "output"))
973
+ print("❌ Failed: Should have raised ValueError for source outside allowed directories")
974
+ return False
975
+ except ValueError as e:
976
+ if "Path not in allowed directories" in str(e):
977
+ print(" ✓ Correctly blocked source outside allowed directories")
978
+ else:
979
+ print(f"❌ Failed: Unexpected error: {e}")
980
+ return False
981
+ print(" Testing empty destination_base_path...")
982
+ try:
983
+ file_pairs = get_copy_file_pairs(allowed_paths, str(test_source_dir), "")
984
+ print("❌ Failed: Should have raised ValueError for empty destination_base_path")
985
+ return False
986
+ except ValueError as e:
987
+ if "destination_base_path is required" in str(e):
988
+ print(" ✓ Correctly required destination_base_path")
989
+ else:
990
+ print(f"❌ Failed: Unexpected error: {e}")
991
+ return False
992
+ print(" Testing _validate_path_access function...")
993
+ try:
994
+ # Use resolve() to handle macOS /private prefix differences
995
+ test_path = (helper.workspace_dir / "test.txt").resolve()
996
+ resolved_allowed_paths = [p.resolve() for p in allowed_paths]
997
+ _validate_path_access(test_path, resolved_allowed_paths)
998
+ print(" ✓ Valid path accepted")
999
+ except Exception as e:
1000
+ print(f"❌ Failed: Valid path should be accepted. Error: {e}")
1001
+ return False
1002
+ try:
1003
+ # Use resolve() to handle macOS /private prefix differences
1004
+ test_path = (outside_dir / "test.txt").resolve()
1005
+ resolved_allowed_paths = [p.resolve() for p in allowed_paths]
1006
+ _validate_path_access(test_path, resolved_allowed_paths)
1007
+ print("❌ Failed: Invalid path should be rejected")
1008
+ return False
1009
+ except ValueError as e:
1010
+ if "Path not in allowed directories" in str(e):
1011
+ print(" ✓ Invalid path correctly rejected")
1012
+ else:
1013
+ print(f"❌ Failed: Unexpected error: {e}")
1014
+ return False
1015
+
1016
+ # Test relative path resolution with workspace context
1017
+ print(" Testing relative path resolution...")
1018
+ original_cwd = os.getcwd()
1019
+ try:
1020
+ # Change to workspace directory to simulate the new factory function approach
1021
+ os.chdir(str(helper.workspace_dir))
1022
+ source, dest = _validate_and_resolve_paths(allowed_paths, str(test_source_dir / "test_file.txt"), "subdir/relative_dest.txt")
1023
+ expected_dest = helper.workspace_dir / "subdir" / "relative_dest.txt"
1024
+ if dest != expected_dest.resolve():
1025
+ print(f"❌ Failed: Relative path should resolve to {expected_dest.resolve()}, got {dest}")
1026
+ return False
1027
+ print(" ✓ Relative path correctly resolved to workspace")
1028
+ except Exception as e:
1029
+ print(f"❌ Failed: Relative path resolution failed: {e}")
1030
+ return False
1031
+ finally:
1032
+ os.chdir(original_cwd)
1033
+
1034
+ print("✅ Workspace copy server path validation works correctly")
1035
+ return True
1036
+ finally:
1037
+ helper.teardown()
1038
+
1039
+
1040
+ def test_file_context_paths():
1041
+ print("\n📄 Testing file-based context paths...")
1042
+
1043
+ helper = TestHelper()
1044
+ helper.setup()
1045
+
1046
+ try:
1047
+ # Create test files
1048
+ test_file = helper.context_dir / "important_file.txt"
1049
+ test_file.write_text("important content")
1050
+ sibling_file = helper.context_dir / "sibling_file.txt"
1051
+ sibling_file.write_text("sibling content")
1052
+ another_sibling = helper.context_dir / "another_file.txt"
1053
+ another_sibling.write_text("another content")
1054
+
1055
+ # Create manager with file-specific context path
1056
+ manager = PathPermissionManager(context_write_access_enabled=False)
1057
+ manager.add_path(helper.workspace_dir, Permission.WRITE, "workspace")
1058
+
1059
+ # Add file context path (not directory)
1060
+ file_context_paths = [{"path": str(test_file), "permission": "read"}]
1061
+ manager.add_context_paths(file_context_paths)
1062
+
1063
+ print(" Testing file gets read permission...")
1064
+ permission = manager.get_permission(test_file)
1065
+ if permission != Permission.READ:
1066
+ print(f"❌ Failed: File should have read permission, got {permission}")
1067
+ return False
1068
+
1069
+ print(" Testing sibling file has no permission...")
1070
+ permission = manager.get_permission(sibling_file)
1071
+ if permission is not None:
1072
+ print(f"❌ Failed: Sibling file should have no permission, got {permission}")
1073
+ return False
1074
+
1075
+ print(" Testing parent directory has no direct permission...")
1076
+ permission = manager.get_permission(helper.context_dir)
1077
+ if permission is not None:
1078
+ print(f"❌ Failed: Parent directory should have no permission, got {permission}")
1079
+ return False
1080
+
1081
+ print(" Testing write tool access to sibling file is blocked...")
1082
+ # Try to write to sibling file - should be blocked
1083
+ tool_args = {"file_path": str(sibling_file)}
1084
+ allowed, reason = manager._validate_write_tool("Write", tool_args)
1085
+ if allowed:
1086
+ print("❌ Failed: Write to sibling file should be blocked")
1087
+ return False
1088
+ if "not an explicitly allowed file" not in reason:
1089
+ print(f"❌ Failed: Expected 'not an explicitly allowed file' in reason, got: {reason}")
1090
+ return False
1091
+
1092
+ print(" Testing write tool access to another sibling is also blocked...")
1093
+ tool_args = {"file_path": str(another_sibling)}
1094
+ allowed, reason = manager._validate_write_tool("Write", tool_args)
1095
+ if allowed:
1096
+ print("❌ Failed: Write to another sibling should be blocked")
1097
+ return False
1098
+
1099
+ print(" Testing read tool access to allowed file works...")
1100
+ # Try to read the explicitly allowed file - should work
1101
+ tool_args = {"file_path": str(test_file)}
1102
+ allowed, reason = manager._validate_write_tool("Read", tool_args)
1103
+ # Read tools are always allowed
1104
+ if not allowed:
1105
+ print(f"❌ Failed: Read of allowed file should work. Reason: {reason}")
1106
+ return False
1107
+
1108
+ print(" Testing file context path with write permission...")
1109
+ manager2 = PathPermissionManager(context_write_access_enabled=True)
1110
+ manager2.add_path(helper.workspace_dir, Permission.WRITE, "workspace")
1111
+ file_context_paths2 = [{"path": str(test_file), "permission": "write"}]
1112
+ manager2.add_context_paths(file_context_paths2)
1113
+
1114
+ permission = manager2.get_permission(test_file)
1115
+ if permission != Permission.WRITE:
1116
+ print(f"❌ Failed: File should have write permission when enabled, got {permission}")
1117
+ return False
1118
+
1119
+ print(" Testing write to allowed file works with write permission...")
1120
+ tool_args = {"file_path": str(test_file)}
1121
+ allowed, reason = manager2._validate_write_tool("Write", tool_args)
1122
+ if not allowed:
1123
+ print(f"❌ Failed: Write to allowed file should work with write permission. Reason: {reason}")
1124
+ return False
1125
+
1126
+ print(" Testing write to sibling still blocked even with write-enabled file context...")
1127
+ tool_args = {"file_path": str(sibling_file)}
1128
+ allowed, reason = manager2._validate_write_tool("Write", tool_args)
1129
+ if allowed:
1130
+ print("❌ Failed: Write to sibling should still be blocked")
1131
+ return False
1132
+
1133
+ print(" Testing parent directory still has no MCP paths...")
1134
+ mcp_paths = manager.get_mcp_filesystem_paths()
1135
+ # Parent should be in allowed paths for MCP access but not grant permissions
1136
+ if str(helper.context_dir.resolve()) not in mcp_paths:
1137
+ print("❌ Failed: Parent directory should be in MCP allowed paths for file access")
1138
+ return False
1139
+
1140
+ print(" Testing deletion of sibling file is blocked...")
1141
+ tool_args = {"path": str(sibling_file)}
1142
+ allowed, reason = manager._validate_write_tool("delete_file", tool_args)
1143
+ if allowed:
1144
+ print("❌ Failed: Deletion of sibling file should be blocked")
1145
+ return False
1146
+
1147
+ print(" Testing copy to sibling location is blocked...")
1148
+ tool_args = {
1149
+ "source_path": str(helper.workspace_dir / "workspace_file.txt"),
1150
+ "destination_path": str(another_sibling),
1151
+ }
1152
+ allowed, reason = manager._validate_write_tool("copy_file", tool_args)
1153
+ if allowed:
1154
+ print("❌ Failed: Copy to sibling location should be blocked")
1155
+ return False
1156
+
1157
+ print("✅ File-based context paths work correctly")
1158
+ return True
1159
+
1160
+ finally:
1161
+ helper.teardown()
1162
+
1163
+
1164
+ def test_delete_operations():
1165
+ print("\n🗑️ Testing deletion operations...")
1166
+
1167
+ helper = TestHelper()
1168
+ helper.setup()
1169
+
1170
+ try:
1171
+ manager = helper.create_permission_manager(context_write_enabled=False)
1172
+
1173
+ print(" Testing delete_file detected as write tool...")
1174
+ if not manager._is_write_tool("delete_file"):
1175
+ print("❌ Failed: delete_file should be detected as write tool")
1176
+ return False
1177
+
1178
+ if not manager._is_write_tool("delete_files_batch"):
1179
+ print("❌ Failed: delete_files_batch should be detected as write tool")
1180
+ return False
1181
+
1182
+ print(" Testing deletion permission validation...")
1183
+ # Test workspace deletion (allowed)
1184
+ test_file = helper.workspace_dir / "test.txt"
1185
+ test_file.write_text("content")
1186
+ tool_args = {"path": str(test_file)}
1187
+ allowed, reason = manager._validate_write_tool("delete_file", tool_args)
1188
+ if not allowed:
1189
+ print(f"❌ Failed: Workspace file deletion should be allowed. Reason: {reason}")
1190
+ return False
1191
+
1192
+ # Test read-only context deletion (blocked)
1193
+ readonly_file = helper.readonly_dir / "readonly_file.txt"
1194
+ tool_args = {"path": str(readonly_file)}
1195
+ allowed, reason = manager._validate_write_tool("delete_file", tool_args)
1196
+ if allowed:
1197
+ print("❌ Failed: Read-only file deletion should be blocked")
1198
+ return False
1199
+ if "read-only context path" not in reason:
1200
+ print(f"❌ Failed: Expected 'read-only context path' in reason, got: {reason}")
1201
+ return False
1202
+
1203
+ # Test writable context deletion (allowed)
1204
+ manager2 = helper.create_permission_manager(context_write_enabled=True)
1205
+ context_file = helper.context_dir / "context_file.txt"
1206
+ tool_args = {"path": str(context_file)}
1207
+ allowed, reason = manager2._validate_write_tool("delete_file", tool_args)
1208
+ if not allowed:
1209
+ print(f"❌ Failed: Writable context file deletion should be allowed. Reason: {reason}")
1210
+ return False
1211
+
1212
+ print(" Testing batch deletion permissions...")
1213
+ # Create multiple files
1214
+ for i in range(3):
1215
+ (helper.workspace_dir / f"file{i}.txt").write_text(f"content {i}")
1216
+
1217
+ tool_args = {"base_path": str(helper.workspace_dir), "include_patterns": ["*.txt"]}
1218
+ allowed, reason = manager._validate_write_tool("delete_files_batch", tool_args)
1219
+ # Note: This should succeed because workspace is writable
1220
+ # The actual deletion logic is in workspace_tools_server
1221
+
1222
+ print("✅ Deletion operation permissions work correctly")
1223
+ return True
1224
+
1225
+ finally:
1226
+ helper.teardown()
1227
+
1228
+
1229
+ def test_permission_path_root_protection():
1230
+ print("\n🛡️ Testing permission path root protection...")
1231
+
1232
+ helper = TestHelper()
1233
+ helper.setup()
1234
+
1235
+ try:
1236
+ from massgen.filesystem_manager._workspace_tools_server import (
1237
+ _is_permission_path_root,
1238
+ )
1239
+
1240
+ print(" Testing workspace root is protected...")
1241
+ # The workspace root itself should be protected
1242
+ if not _is_permission_path_root(helper.workspace_dir, [helper.workspace_dir]):
1243
+ print("❌ Failed: Workspace root should be protected from deletion")
1244
+ return False
1245
+
1246
+ print(" Testing files within workspace are NOT protected...")
1247
+ # Files/dirs inside workspace should NOT be protected
1248
+ test_file = helper.workspace_dir / "file.txt"
1249
+ test_file.write_text("content")
1250
+ if _is_permission_path_root(test_file, [helper.workspace_dir]):
1251
+ print("❌ Failed: Files within workspace should not be protected by root check")
1252
+ return False
1253
+
1254
+ test_subdir = helper.workspace_dir / "subdir"
1255
+ test_subdir.mkdir()
1256
+ if _is_permission_path_root(test_subdir, [helper.workspace_dir]):
1257
+ print("❌ Failed: Subdirs within workspace should not be protected by root check")
1258
+ return False
1259
+
1260
+ print(" Testing nested directories are NOT protected...")
1261
+ nested = helper.workspace_dir / "a" / "b" / "c"
1262
+ nested.mkdir(parents=True)
1263
+ if _is_permission_path_root(nested, [helper.workspace_dir]):
1264
+ print("❌ Failed: Nested directories should not be protected by root check")
1265
+ return False
1266
+
1267
+ print(" Testing system files still protected within workspace...")
1268
+ from massgen.filesystem_manager._workspace_tools_server import _is_critical_path
1269
+
1270
+ system_dir = helper.workspace_dir / ".massgen"
1271
+ system_dir.mkdir()
1272
+ # Pass allowed_paths so it checks within workspace context
1273
+ if not _is_critical_path(system_dir, [helper.workspace_dir]):
1274
+ print("❌ Failed: .massgen should still be protected by critical path check")
1275
+ return False
1276
+
1277
+ # But workspace root itself is NOT a critical path (when checking within allowed paths)
1278
+ if _is_critical_path(helper.workspace_dir, [helper.workspace_dir]):
1279
+ print("❌ Failed: Workspace root should not be a critical path when within allowed paths")
1280
+ return False
1281
+
1282
+ # Regular user directory within workspace should not be critical
1283
+ user_dir = helper.workspace_dir / "user_project"
1284
+ user_dir.mkdir()
1285
+ if _is_critical_path(user_dir, [helper.workspace_dir]):
1286
+ print("❌ Failed: Regular user directory should not be critical within workspace")
1287
+ return False
1288
+
1289
+ print(" Testing real-world scenario: workspace under .massgen/workspaces/...")
1290
+ # This is the critical test that was missing!
1291
+ # Simulate real workspace path: /project/.massgen/workspaces/workspace1/
1292
+ massgen_dir = helper.temp_dir / ".massgen"
1293
+ massgen_dir.mkdir()
1294
+ workspaces_dir = massgen_dir / "workspaces"
1295
+ workspaces_dir.mkdir()
1296
+ real_workspace = workspaces_dir / "workspace1"
1297
+ real_workspace.mkdir()
1298
+
1299
+ # User creates a directory in their workspace
1300
+ user_project = real_workspace / "bob_dylan_website"
1301
+ user_project.mkdir()
1302
+ (user_project / "index.html").write_text("<html></html>")
1303
+
1304
+ # This should NOT be blocked even though path contains .massgen
1305
+ if _is_critical_path(user_project, [real_workspace]):
1306
+ print("❌ Failed: User project should not be critical within workspace even if parent has .massgen")
1307
+ print(f" Path: {user_project}")
1308
+ print(f" Workspace: {real_workspace}")
1309
+ return False
1310
+
1311
+ # But system files within that workspace should still be blocked
1312
+ git_dir = real_workspace / ".git"
1313
+ git_dir.mkdir()
1314
+ if not _is_critical_path(git_dir, [real_workspace]):
1315
+ print("❌ Failed: .git should still be critical within workspace")
1316
+ return False
1317
+
1318
+ # And .massgen itself within workspace should be blocked
1319
+ massgen_subdir = real_workspace / ".massgen"
1320
+ massgen_subdir.mkdir()
1321
+ if not _is_critical_path(massgen_subdir, [real_workspace]):
1322
+ print("❌ Failed: .massgen subdir should be critical within workspace")
1323
+ return False
1324
+
1325
+ print(" Testing multiple permission paths...")
1326
+ allowed_paths = [helper.workspace_dir, helper.context_dir, helper.readonly_dir]
1327
+
1328
+ # All roots should be protected
1329
+ for path in allowed_paths:
1330
+ if not _is_permission_path_root(path, allowed_paths):
1331
+ print(f"❌ Failed: {path} should be protected as root")
1332
+ return False
1333
+
1334
+ # Files within any root should not be protected
1335
+ for root_dir in allowed_paths:
1336
+ test_file = root_dir / "test.txt"
1337
+ test_file.write_text("test")
1338
+ if _is_permission_path_root(test_file, allowed_paths):
1339
+ print(f"❌ Failed: File {test_file} should not be protected as root")
1340
+ return False
1341
+
1342
+ print("✅ Permission path root protection works correctly")
1343
+ return True
1344
+
1345
+ finally:
1346
+ helper.teardown()
1347
+
1348
+
1349
+ def test_protected_paths():
1350
+ print("\n🛡️ Testing protected paths feature...")
1351
+
1352
+ helper = TestHelper()
1353
+ helper.setup()
1354
+
1355
+ try:
1356
+ # Create test structure
1357
+ test_dir = helper.temp_dir / "test_project"
1358
+ test_dir.mkdir()
1359
+ (test_dir / "modifiable.txt").write_text("can modify")
1360
+ (test_dir / "protected.txt").write_text("cannot modify")
1361
+ protected_dir = test_dir / "protected_dir"
1362
+ protected_dir.mkdir()
1363
+ (protected_dir / "nested.txt").write_text("also protected")
1364
+
1365
+ print(" Testing protected paths configuration...")
1366
+ manager = PathPermissionManager(context_write_access_enabled=True)
1367
+
1368
+ # Add context path with protected paths
1369
+ context_paths = [
1370
+ {
1371
+ "path": str(test_dir),
1372
+ "permission": "write",
1373
+ "protected_paths": ["protected.txt", "protected_dir/"], # Relative paths
1374
+ },
1375
+ ]
1376
+ manager.add_context_paths(context_paths)
1377
+
1378
+ print(" Testing modifiable file has WRITE permission...")
1379
+ modifiable = test_dir / "modifiable.txt"
1380
+ permission = manager.get_permission(modifiable)
1381
+ if permission != Permission.WRITE:
1382
+ print(f"❌ Failed: Modifiable file should have WRITE, got {permission}")
1383
+ return False
1384
+
1385
+ print(" Testing protected file has READ permission...")
1386
+ protected_file = test_dir / "protected.txt"
1387
+ permission = manager.get_permission(protected_file)
1388
+ if permission != Permission.READ:
1389
+ print(f"❌ Failed: Protected file should have READ (forced), got {permission}")
1390
+ return False
1391
+
1392
+ print(" Testing files in protected directory have READ permission...")
1393
+ nested_file = protected_dir / "nested.txt"
1394
+ permission = manager.get_permission(nested_file)
1395
+ if permission != Permission.READ:
1396
+ print(f"❌ Failed: File in protected dir should have READ, got {permission}")
1397
+ return False
1398
+
1399
+ print(" Testing protected directory itself has READ permission...")
1400
+ permission = manager.get_permission(protected_dir)
1401
+ if permission != Permission.READ:
1402
+ print(f"❌ Failed: Protected directory should have READ, got {permission}")
1403
+ return False
1404
+
1405
+ print(" Testing write tool validation on protected paths...")
1406
+ # Try to write to protected file (should be blocked)
1407
+ tool_args = {"file_path": str(protected_file)}
1408
+ allowed, reason = manager._validate_write_tool("Write", tool_args)
1409
+ if allowed:
1410
+ print("❌ Failed: Write to protected file should be blocked")
1411
+ return False
1412
+ if "read-only" not in reason.lower():
1413
+ print(f"❌ Failed: Expected 'read-only' in reason, got: {reason}")
1414
+ return False
1415
+
1416
+ # Try to delete protected file (should be blocked)
1417
+ tool_args = {"path": str(protected_file)}
1418
+ allowed, reason = manager._validate_write_tool("delete_file", tool_args)
1419
+ if allowed:
1420
+ print("❌ Failed: Delete of protected file should be blocked")
1421
+ return False
1422
+
1423
+ # Try to write to modifiable file (should be allowed)
1424
+ tool_args = {"file_path": str(modifiable)}
1425
+ allowed, reason = manager._validate_write_tool("Write", tool_args)
1426
+ if not allowed:
1427
+ print(f"❌ Failed: Write to modifiable file should be allowed. Reason: {reason}")
1428
+ return False
1429
+
1430
+ print(" Testing absolute protected paths...")
1431
+ test_dir2 = helper.temp_dir / "test_project2"
1432
+ test_dir2.mkdir()
1433
+ (test_dir2 / "file.txt").write_text("content")
1434
+ protected_abs = test_dir2 / "protected_abs.txt"
1435
+ protected_abs.write_text("absolutely protected")
1436
+
1437
+ manager2 = PathPermissionManager(context_write_access_enabled=True)
1438
+ context_paths2 = [
1439
+ {
1440
+ "path": str(test_dir2),
1441
+ "permission": "write",
1442
+ "protected_paths": [str(protected_abs)], # Absolute path
1443
+ },
1444
+ ]
1445
+ manager2.add_context_paths(context_paths2)
1446
+
1447
+ permission = manager2.get_permission(protected_abs)
1448
+ if permission != Permission.READ:
1449
+ print(f"❌ Failed: Absolutely protected file should have READ, got {permission}")
1450
+ return False
1451
+
1452
+ print(" Testing protected paths outside context path are ignored...")
1453
+ test_dir3 = helper.temp_dir / "test_project3"
1454
+ test_dir3.mkdir()
1455
+ outside_file = helper.temp_dir / "outside.txt"
1456
+ outside_file.write_text("outside")
1457
+
1458
+ manager3 = PathPermissionManager(context_write_access_enabled=True)
1459
+ context_paths3 = [
1460
+ {
1461
+ "path": str(test_dir3),
1462
+ "permission": "write",
1463
+ "protected_paths": [str(outside_file)], # Outside context path
1464
+ },
1465
+ ]
1466
+ # This should log a warning and skip the protected path
1467
+ manager3.add_context_paths(context_paths3)
1468
+
1469
+ print("✅ Protected paths work correctly")
1470
+ return True
1471
+
1472
+ finally:
1473
+ helper.teardown()
1474
+
1475
+
1476
+ async def test_delete_file_real_workspace_scenario():
1477
+ print("\n🧪 Testing delete_file with real .massgen/workspaces/ path...")
1478
+
1479
+ helper = TestHelper()
1480
+ helper.setup()
1481
+
1482
+ try:
1483
+ # Simulate REAL MassGen workspace structure
1484
+ massgen_root = helper.temp_dir / ".massgen"
1485
+ massgen_root.mkdir()
1486
+ workspaces_dir = massgen_root / "workspaces"
1487
+ workspaces_dir.mkdir()
1488
+ workspace = workspaces_dir / "workspace1"
1489
+ workspace.mkdir()
1490
+
1491
+ # User creates files in their workspace
1492
+ user_project = workspace / "my_website"
1493
+ user_project.mkdir()
1494
+ index_file = user_project / "index.html"
1495
+ index_file.write_text("<html><body>Hello World</body></html>")
1496
+ styles_file = user_project / "styles.css"
1497
+ styles_file.write_text("body { color: blue; }")
1498
+
1499
+ print(f" Created test workspace at: {workspace}")
1500
+ print(f" User project: {user_project}")
1501
+
1502
+ # Import the helper functions directly to test logic
1503
+ from massgen.filesystem_manager._workspace_tools_server import (
1504
+ _is_critical_path,
1505
+ _is_permission_path_root,
1506
+ )
1507
+
1508
+ # Test 1: User file should NOT be critical (key test!)
1509
+ print(" Testing that user file is not critical...")
1510
+ if _is_critical_path(index_file, [workspace]):
1511
+ print("❌ Failed: User file should not be critical")
1512
+ print(f" Path: {index_file}")
1513
+ print(f" Workspace: {workspace}")
1514
+ return False
1515
+
1516
+ print(" ✓ User file correctly allowed")
1517
+
1518
+ # Test 2: User directory should NOT be critical
1519
+ print(" Testing that user directory is not critical...")
1520
+ if _is_critical_path(user_project, [workspace]):
1521
+ print("❌ Failed: User directory should not be critical")
1522
+ print(f" Path: {user_project}")
1523
+ return False
1524
+
1525
+ print(" ✓ User directory correctly allowed")
1526
+
1527
+ # Test 3: .git within workspace SHOULD be critical
1528
+ git_dir = workspace / ".git"
1529
+ git_dir.mkdir()
1530
+
1531
+ print(" Testing that .git is still protected...")
1532
+ if not _is_critical_path(git_dir, [workspace]):
1533
+ print("❌ Failed: .git should be critical within workspace")
1534
+ return False
1535
+
1536
+ print(" ✓ .git correctly blocked")
1537
+
1538
+ # Test 4: .env within workspace SHOULD be critical
1539
+ env_file = workspace / ".env"
1540
+ env_file.write_text("SECRET=123")
1541
+
1542
+ print(" Testing that .env is still protected...")
1543
+ if not _is_critical_path(env_file, [workspace]):
1544
+ print("❌ Failed: .env should be critical within workspace")
1545
+ return False
1546
+
1547
+ print(" ✓ .env correctly blocked")
1548
+
1549
+ # Test 5: Workspace root SHOULD be protected
1550
+ print(" Testing that workspace root is protected...")
1551
+ if not _is_permission_path_root(workspace, [workspace]):
1552
+ print("❌ Failed: Workspace root should be protected")
1553
+ return False
1554
+
1555
+ print(" ✓ Workspace root correctly blocked")
1556
+
1557
+ print("✅ Real workspace deletion scenario works correctly")
1558
+ return True
1559
+
1560
+ finally:
1561
+ helper.teardown()
1562
+
1563
+
1564
+ async def test_compare_tools():
1565
+ print("\n🔍 Testing comparison tools...")
1566
+
1567
+ helper = TestHelper()
1568
+ helper.setup()
1569
+
1570
+ try:
1571
+ print(" Testing compare tools are not write tools...")
1572
+ manager = helper.create_permission_manager()
1573
+
1574
+ if manager._is_write_tool("compare_directories"):
1575
+ print("❌ Failed: compare_directories should not be write tool")
1576
+ return False
1577
+
1578
+ if manager._is_write_tool("compare_files"):
1579
+ print("❌ Failed: compare_files should not be write tool")
1580
+ return False
1581
+
1582
+ print(" Testing compare operations are always allowed...")
1583
+ # Compare tools should never be blocked since they're read-only
1584
+ tool_args = {"dir1": str(helper.workspace_dir), "dir2": str(helper.context_dir)}
1585
+ allowed, reason = await manager.pre_tool_use_hook("compare_directories", tool_args)
1586
+ if not allowed:
1587
+ print(f"❌ Failed: compare_directories should be allowed. Reason: {reason}")
1588
+ return False
1589
+
1590
+ tool_args = {"file1": str(helper.workspace_dir / "workspace_file.txt"), "file2": str(helper.context_dir / "context_file.txt")}
1591
+ allowed, reason = await manager.pre_tool_use_hook("compare_files", tool_args)
1592
+ if not allowed:
1593
+ print(f"❌ Failed: compare_files should be allowed. Reason: {reason}")
1594
+ return False
1595
+
1596
+ print("✅ Comparison tools work correctly")
1597
+ return True
1598
+
1599
+ finally:
1600
+ helper.teardown()
1601
+
1602
+
1603
+ def test_file_operation_tracker():
1604
+ print("\n📊 Testing FileOperationTracker...")
1605
+
1606
+ helper = TestHelper()
1607
+ helper.setup()
1608
+
1609
+ try:
1610
+ tracker = FileOperationTracker(enforce_read_before_delete=True)
1611
+
1612
+ print(" Testing file read tracking...")
1613
+ test_file = helper.workspace_dir / "test.txt"
1614
+ test_file.write_text("content")
1615
+
1616
+ # File not read yet
1617
+ if tracker.was_read(test_file):
1618
+ print("❌ Failed: File should not be marked as read initially")
1619
+ return False
1620
+
1621
+ # Mark as read
1622
+ tracker.mark_as_read(test_file)
1623
+
1624
+ if not tracker.was_read(test_file):
1625
+ print("❌ Failed: File should be marked as read after mark_as_read")
1626
+ return False
1627
+
1628
+ print(" Testing created file tracking...")
1629
+ created_file = helper.workspace_dir / "created.txt"
1630
+ created_file.write_text("new content")
1631
+
1632
+ tracker.mark_as_created(created_file)
1633
+
1634
+ if not tracker.was_read(created_file):
1635
+ print("❌ Failed: Created file should count as 'read'")
1636
+ return False
1637
+
1638
+ print(" Testing delete validation...")
1639
+ # Can delete read file
1640
+ can_delete, reason = tracker.can_delete(test_file)
1641
+ if not can_delete:
1642
+ print(f"❌ Failed: Should allow delete of read file. Reason: {reason}")
1643
+ return False
1644
+
1645
+ # Cannot delete unread file
1646
+ unread_file = helper.workspace_dir / "unread.txt"
1647
+ unread_file.write_text("unread content")
1648
+ can_delete, reason = tracker.can_delete(unread_file)
1649
+ if can_delete:
1650
+ print("❌ Failed: Should block delete of unread file")
1651
+ return False
1652
+ if "must be read before deletion" not in reason:
1653
+ print(f"❌ Failed: Expected 'must be read before deletion' in reason, got: {reason}")
1654
+ return False
1655
+
1656
+ # Can delete created file (even if not explicitly read)
1657
+ can_delete, reason = tracker.can_delete(created_file)
1658
+ if not can_delete:
1659
+ print(f"❌ Failed: Should allow delete of created file. Reason: {reason}")
1660
+ return False
1661
+
1662
+ print(" Testing directory delete validation...")
1663
+ test_dir = helper.workspace_dir / "test_dir"
1664
+ test_dir.mkdir()
1665
+ (test_dir / "file1.txt").write_text("content 1")
1666
+ (test_dir / "file2.txt").write_text("content 2")
1667
+
1668
+ # Cannot delete directory with unread files
1669
+ can_delete, reason = tracker.can_delete_directory(test_dir)
1670
+ if can_delete:
1671
+ print("❌ Failed: Should block delete of directory with unread files")
1672
+ return False
1673
+
1674
+ # Mark files as read
1675
+ tracker.mark_as_read(test_dir / "file1.txt")
1676
+ tracker.mark_as_read(test_dir / "file2.txt")
1677
+
1678
+ # Now can delete
1679
+ can_delete, reason = tracker.can_delete_directory(test_dir)
1680
+ if not can_delete:
1681
+ print(f"❌ Failed: Should allow delete of directory with all files read. Reason: {reason}")
1682
+ return False
1683
+
1684
+ print(" Testing tracker stats...")
1685
+ stats = tracker.get_stats()
1686
+ if stats["read_files"] < 3: # test_file + file1 + file2
1687
+ print(f"❌ Failed: Expected at least 3 read files, got {stats['read_files']}")
1688
+ return False
1689
+ if stats["created_files"] < 1: # created_file
1690
+ print(f"❌ Failed: Expected at least 1 created file, got {stats['created_files']}")
1691
+ return False
1692
+
1693
+ print(" Testing tracker clear...")
1694
+ tracker.clear()
1695
+ stats = tracker.get_stats()
1696
+ if stats["read_files"] != 0 or stats["created_files"] != 0:
1697
+ print(f"❌ Failed: Tracker should be empty after clear, got {stats}")
1698
+ return False
1699
+
1700
+ print(" Testing disabled enforcement...")
1701
+ tracker_disabled = FileOperationTracker(enforce_read_before_delete=False)
1702
+ can_delete, reason = tracker_disabled.can_delete(unread_file)
1703
+ if not can_delete:
1704
+ print("❌ Failed: Should allow delete when enforcement disabled")
1705
+ return False
1706
+
1707
+ print("✅ FileOperationTracker works correctly")
1708
+ return True
1709
+
1710
+ finally:
1711
+ helper.teardown()
1712
+
1713
+
1714
+ async def test_read_before_delete_tracking():
1715
+ print("\n📖 Testing read-before-delete tracking...")
1716
+
1717
+ helper = TestHelper()
1718
+ helper.setup()
1719
+
1720
+ try:
1721
+ # Create manager with read-before-delete enabled
1722
+ manager = PathPermissionManager(context_write_access_enabled=True, enforce_read_before_delete=True)
1723
+ manager.add_path(helper.workspace_dir, Permission.WRITE, "workspace")
1724
+
1725
+ # Create test files
1726
+ file1 = helper.workspace_dir / "file1.txt"
1727
+ file1.write_text("content 1")
1728
+ file2 = helper.workspace_dir / "file2.txt"
1729
+ file2.write_text("content 2")
1730
+
1731
+ print(" Testing Read tool tracking...")
1732
+ # Read file1
1733
+ tool_args = {"file_path": str(file1)}
1734
+ allowed, reason = await manager.pre_tool_use_hook("Read", tool_args)
1735
+
1736
+ # Should be tracked as read
1737
+ if not manager.file_operation_tracker.was_read(file1):
1738
+ print("❌ Failed: Read tool should track file as read")
1739
+ return False
1740
+
1741
+ print(" Testing Write tool tracking (creates file)...")
1742
+ new_file = helper.workspace_dir / "new_file.txt"
1743
+ tool_args = {"file_path": str(new_file)}
1744
+ allowed, reason = await manager.pre_tool_use_hook("Write", tool_args)
1745
+
1746
+ # Write should track file as created
1747
+ if not manager.file_operation_tracker.was_read(new_file):
1748
+ print("❌ Failed: Write tool should track file as created")
1749
+ return False
1750
+
1751
+ print(" Testing read_multimodal_files tracking...")
1752
+ image_file = helper.workspace_dir / "image.png"
1753
+ image_file.write_text("fake image data")
1754
+ tool_args = {"path": str(image_file)}
1755
+ allowed, reason = await manager.pre_tool_use_hook("read_multimodal_files", tool_args)
1756
+
1757
+ if not manager.file_operation_tracker.was_read(image_file):
1758
+ print("❌ Failed: read_multimodal_files should track file as read")
1759
+ return False
1760
+
1761
+ # Reset tracking for MCP version test
1762
+ manager.file_operation_tracker = FileOperationTracker()
1763
+
1764
+ print(" Testing mcp__workspace_tools__read_multimodal_files tracking...")
1765
+ image_file2 = helper.workspace_dir / "image2.png"
1766
+ image_file2.write_text("fake image data")
1767
+ tool_args = {"path": str(image_file2)}
1768
+ allowed, reason = await manager.pre_tool_use_hook("mcp__workspace_tools__read_multimodal_files", tool_args)
1769
+
1770
+ if not manager.file_operation_tracker.was_read(image_file2):
1771
+ print("❌ Failed: mcp__workspace_tools__read_multimodal_files should track file as read")
1772
+ return False
1773
+
1774
+ print(" Testing mcp__filesystem__read_text_file tracking...")
1775
+ text_file = helper.workspace_dir / "text.txt"
1776
+ text_file.write_text("test content")
1777
+ tool_args = {"path": str(text_file)}
1778
+ allowed, reason = await manager.pre_tool_use_hook("mcp__filesystem__read_text_file", tool_args)
1779
+
1780
+ if not manager.file_operation_tracker.was_read(text_file):
1781
+ print("❌ Failed: mcp__filesystem__read_text_file should track file as read")
1782
+ return False
1783
+
1784
+ print(" Testing mcp__filesystem__read_multiple_files tracking...")
1785
+ file3 = helper.workspace_dir / "file3.txt"
1786
+ file4 = helper.workspace_dir / "file4.txt"
1787
+ file3.write_text("content3")
1788
+ file4.write_text("content4")
1789
+ tool_args = {"paths": [str(file3), str(file4)]}
1790
+ allowed, reason = await manager.pre_tool_use_hook("mcp__filesystem__read_multiple_files", tool_args)
1791
+
1792
+ if not manager.file_operation_tracker.was_read(file3):
1793
+ print("❌ Failed: mcp__filesystem__read_multiple_files should track file3 as read")
1794
+ return False
1795
+ if not manager.file_operation_tracker.was_read(file4):
1796
+ print("❌ Failed: mcp__filesystem__read_multiple_files should track file4 as read")
1797
+ return False
1798
+
1799
+ print(" Testing compare_files tracking...")
1800
+ tool_args = {"file1": str(file1), "file2": str(file2)}
1801
+ allowed, reason = await manager.pre_tool_use_hook("compare_files", tool_args)
1802
+
1803
+ # Both files should be tracked
1804
+ if not manager.file_operation_tracker.was_read(file1):
1805
+ print("❌ Failed: compare_files should track file1 as read")
1806
+ return False
1807
+ if not manager.file_operation_tracker.was_read(file2):
1808
+ print("❌ Failed: compare_files should track file2 as read")
1809
+ return False
1810
+
1811
+ print("✅ Read-before-delete tracking works correctly")
1812
+ return True
1813
+
1814
+ finally:
1815
+ helper.teardown()
1816
+
1817
+
1818
+ async def test_delete_validation_with_read_requirement():
1819
+ print("\n🗑️ Testing delete validation with read requirement...")
1820
+
1821
+ helper = TestHelper()
1822
+ helper.setup()
1823
+
1824
+ try:
1825
+ # Create manager with read-before-delete enabled
1826
+ manager = PathPermissionManager(context_write_access_enabled=True, enforce_read_before_delete=True)
1827
+ manager.add_path(helper.workspace_dir, Permission.WRITE, "workspace")
1828
+
1829
+ # Create test files
1830
+ read_file = helper.workspace_dir / "read_file.txt"
1831
+ read_file.write_text("content")
1832
+ unread_file = helper.workspace_dir / "unread_file.txt"
1833
+ unread_file.write_text("content")
1834
+
1835
+ print(" Testing delete of unread file is blocked...")
1836
+ tool_args = {"path": str(unread_file)}
1837
+ allowed, reason = await manager.pre_tool_use_hook("delete_file", tool_args)
1838
+
1839
+ if allowed:
1840
+ print("❌ Failed: Delete of unread file should be blocked")
1841
+ return False
1842
+ if "must be read before deletion" not in reason:
1843
+ print(f"❌ Failed: Expected 'must be read before deletion' in reason, got: {reason}")
1844
+ return False
1845
+
1846
+ print(" Testing delete after reading is allowed...")
1847
+ # Read the file first
1848
+ read_args = {"file_path": str(unread_file)}
1849
+ await manager.pre_tool_use_hook("Read", read_args)
1850
+
1851
+ # Now delete should work
1852
+ tool_args = {"path": str(unread_file)}
1853
+ allowed, reason = await manager.pre_tool_use_hook("delete_file", tool_args)
1854
+
1855
+ if not allowed:
1856
+ print(f"❌ Failed: Delete after reading should be allowed. Reason: {reason}")
1857
+ return False
1858
+
1859
+ print(" Testing delete of created file is allowed...")
1860
+ new_file = helper.workspace_dir / "new.txt"
1861
+ write_args = {"file_path": str(new_file)}
1862
+ await manager.pre_tool_use_hook("Write", write_args)
1863
+
1864
+ # Can delete created file without reading
1865
+ tool_args = {"path": str(new_file)}
1866
+ allowed, reason = await manager.pre_tool_use_hook("delete_file", tool_args)
1867
+
1868
+ if not allowed:
1869
+ print(f"❌ Failed: Delete of created file should be allowed. Reason: {reason}")
1870
+ return False
1871
+
1872
+ print(" Testing directory delete with unread files...")
1873
+ test_dir = helper.workspace_dir / "test_dir"
1874
+ test_dir.mkdir()
1875
+ (test_dir / "file1.txt").write_text("content 1")
1876
+ (test_dir / "file2.txt").write_text("content 2")
1877
+
1878
+ tool_args = {"path": str(test_dir), "recursive": True}
1879
+ allowed, reason = await manager.pre_tool_use_hook("delete_file", tool_args)
1880
+
1881
+ if allowed:
1882
+ print("❌ Failed: Delete of directory with unread files should be blocked")
1883
+ return False
1884
+
1885
+ # Read files
1886
+ await manager.pre_tool_use_hook("Read", {"file_path": str(test_dir / "file1.txt")})
1887
+ await manager.pre_tool_use_hook("Read", {"file_path": str(test_dir / "file2.txt")})
1888
+
1889
+ # Now should work
1890
+ tool_args = {"path": str(test_dir), "recursive": True}
1891
+ allowed, reason = await manager.pre_tool_use_hook("delete_file", tool_args)
1892
+
1893
+ if not allowed:
1894
+ print(f"❌ Failed: Delete after reading all files should be allowed. Reason: {reason}")
1895
+ return False
1896
+
1897
+ print("✅ Delete validation with read requirement works correctly")
1898
+ return True
1899
+
1900
+ finally:
1901
+ helper.teardown()
1902
+
1903
+
1904
+ async def test_batch_delete_with_read_requirement():
1905
+ print("\n🗑️📦 Testing batch delete with read requirement...")
1906
+
1907
+ helper = TestHelper()
1908
+ helper.setup()
1909
+
1910
+ try:
1911
+ # Create manager with read-before-delete enabled
1912
+ manager = PathPermissionManager(context_write_access_enabled=True, enforce_read_before_delete=True)
1913
+ manager.add_path(helper.workspace_dir, Permission.WRITE, "workspace")
1914
+
1915
+ # Create test files
1916
+ for i in range(3):
1917
+ (helper.workspace_dir / f"file{i}.txt").write_text(f"content {i}")
1918
+
1919
+ print(" Testing batch delete of unread files is blocked...")
1920
+ tool_args = {"base_path": str(helper.workspace_dir), "include_patterns": ["*.txt"]}
1921
+ allowed, reason = await manager.pre_tool_use_hook("delete_files_batch", tool_args)
1922
+
1923
+ if allowed:
1924
+ print("❌ Failed: Batch delete of unread files should be blocked")
1925
+ return False
1926
+ if "unread file(s)" not in reason:
1927
+ print(f"❌ Failed: Expected 'unread file(s)' in reason, got: {reason}")
1928
+ return False
1929
+
1930
+ print(" Testing batch delete after reading some files...")
1931
+ # Read only file0 and file1
1932
+ await manager.pre_tool_use_hook("Read", {"file_path": str(helper.workspace_dir / "file0.txt")})
1933
+ await manager.pre_tool_use_hook("Read", {"file_path": str(helper.workspace_dir / "file1.txt")})
1934
+
1935
+ # Still should be blocked because file2 is unread
1936
+ tool_args = {"base_path": str(helper.workspace_dir), "include_patterns": ["*.txt"]}
1937
+ allowed, reason = await manager.pre_tool_use_hook("delete_files_batch", tool_args)
1938
+
1939
+ if allowed:
1940
+ print("❌ Failed: Batch delete should still be blocked with unread files")
1941
+ return False
1942
+
1943
+ print(" Testing batch delete after reading all files...")
1944
+ # Read file2
1945
+ await manager.pre_tool_use_hook("Read", {"file_path": str(helper.workspace_dir / "file2.txt")})
1946
+
1947
+ # Now should work
1948
+ tool_args = {"base_path": str(helper.workspace_dir), "include_patterns": ["*.txt"]}
1949
+ allowed, reason = await manager.pre_tool_use_hook("delete_files_batch", tool_args)
1950
+
1951
+ if not allowed:
1952
+ print(f"❌ Failed: Batch delete after reading all should be allowed. Reason: {reason}")
1953
+ return False
1954
+
1955
+ print(" Testing batch delete with exclusions...")
1956
+ # Create new files
1957
+ (helper.workspace_dir / "include1.txt").write_text("include 1")
1958
+ (helper.workspace_dir / "include2.txt").write_text("include 2")
1959
+ (helper.workspace_dir / "exclude1.txt").write_text("exclude 1")
1960
+
1961
+ # Read only included files
1962
+ await manager.pre_tool_use_hook("Read", {"file_path": str(helper.workspace_dir / "include1.txt")})
1963
+ await manager.pre_tool_use_hook("Read", {"file_path": str(helper.workspace_dir / "include2.txt")})
1964
+
1965
+ # Should work because excluded files aren't checked
1966
+ tool_args = {"base_path": str(helper.workspace_dir), "include_patterns": ["include*.txt"], "exclude_patterns": ["exclude*.txt"]}
1967
+ allowed, reason = await manager.pre_tool_use_hook("delete_files_batch", tool_args)
1968
+
1969
+ if not allowed:
1970
+ print(f"❌ Failed: Batch delete with proper exclusions should work. Reason: {reason}")
1971
+ return False
1972
+
1973
+ print("✅ Batch delete with read requirement works correctly")
1974
+ return True
1975
+
1976
+ finally:
1977
+ helper.teardown()
1978
+
1979
+
1980
+ async def test_read_before_delete_disabled():
1981
+ print("\n🔓 Testing read-before-delete when disabled...")
1982
+
1983
+ helper = TestHelper()
1984
+ helper.setup()
1985
+
1986
+ try:
1987
+ # Create manager with read-before-delete DISABLED
1988
+ manager = PathPermissionManager(context_write_access_enabled=True, enforce_read_before_delete=False)
1989
+ manager.add_path(helper.workspace_dir, Permission.WRITE, "workspace")
1990
+
1991
+ # Create unread file
1992
+ unread_file = helper.workspace_dir / "unread.txt"
1993
+ unread_file.write_text("content")
1994
+
1995
+ print(" Testing delete of unread file is allowed when disabled...")
1996
+ tool_args = {"path": str(unread_file)}
1997
+ allowed, reason = await manager.pre_tool_use_hook("delete_file", tool_args)
1998
+
1999
+ if not allowed:
2000
+ print(f"❌ Failed: Delete should be allowed when enforcement disabled. Reason: {reason}")
2001
+ return False
2002
+
2003
+ print(" Testing batch delete of unread files is allowed...")
2004
+ for i in range(3):
2005
+ (helper.workspace_dir / f"batch{i}.txt").write_text(f"content {i}")
2006
+
2007
+ tool_args = {"base_path": str(helper.workspace_dir), "include_patterns": ["batch*.txt"]}
2008
+ allowed, reason = await manager.pre_tool_use_hook("delete_files_batch", tool_args)
2009
+
2010
+ if not allowed:
2011
+ print(f"❌ Failed: Batch delete should be allowed when enforcement disabled. Reason: {reason}")
2012
+ return False
2013
+
2014
+ print("✅ Read-before-delete disabled mode works correctly")
2015
+ return True
2016
+
2017
+ finally:
2018
+ helper.teardown()
2019
+
2020
+
2021
+ async def main():
2022
+ print("\n" + "=" * 60)
2023
+ print("🧪 Path Permission Manager Test Suite")
2024
+ print("=" * 60)
2025
+
2026
+ sync_tests = [
2027
+ test_is_write_tool,
2028
+ test_validate_write_tool,
2029
+ test_validate_command_tool,
2030
+ test_validate_execute_command_tool,
2031
+ test_context_write_access_toggle,
2032
+ test_extract_file_from_command,
2033
+ test_workspace_tools,
2034
+ test_workspace_tools_server_path_validation,
2035
+ test_file_context_paths,
2036
+ test_delete_operations,
2037
+ test_permission_path_root_protection,
2038
+ test_protected_paths,
2039
+ test_file_operation_tracker,
2040
+ ]
2041
+
2042
+ async_tests = [
2043
+ test_pre_tool_use_hook,
2044
+ test_mcp_relative_paths,
2045
+ test_delete_file_real_workspace_scenario,
2046
+ test_compare_tools,
2047
+ test_read_before_delete_tracking,
2048
+ test_delete_validation_with_read_requirement,
2049
+ test_batch_delete_with_read_requirement,
2050
+ test_read_before_delete_disabled,
2051
+ ]
2052
+
2053
+ passed = 0
2054
+ failed = 0
2055
+
2056
+ # Run synchronous tests
2057
+ for test_func in sync_tests:
2058
+ try:
2059
+ if test_func():
2060
+ passed += 1
2061
+ else:
2062
+ failed += 1
2063
+ except Exception as e:
2064
+ print(f"❌ {test_func.__name__} failed with exception: {e}")
2065
+ traceback.print_exc()
2066
+ failed += 1
2067
+
2068
+ # Run asynchronous tests
2069
+ for test_func in async_tests:
2070
+ try:
2071
+ await test_func()
2072
+ passed += 1
2073
+ except Exception as e:
2074
+ print(f"❌ {test_func.__name__} failed with exception: {e}")
2075
+ traceback.print_exc()
2076
+ failed += 1
2077
+
2078
+ print("\n" + "=" * 60)
2079
+ print(f"📊 Test Results: {passed} passed, {failed} failed")
2080
+ print("=" * 60)
2081
+
2082
+ return failed == 0
2083
+
2084
+
2085
+ if __name__ == "__main__":
2086
+ success = asyncio.run(main())
2087
+ sys.exit(0 if success else 1)