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.
- massgen/__init__.py +142 -8
- massgen/adapters/__init__.py +29 -0
- massgen/adapters/ag2_adapter.py +483 -0
- massgen/adapters/base.py +183 -0
- massgen/adapters/tests/__init__.py +0 -0
- massgen/adapters/tests/test_ag2_adapter.py +439 -0
- massgen/adapters/tests/test_agent_adapter.py +128 -0
- massgen/adapters/utils/__init__.py +2 -0
- massgen/adapters/utils/ag2_utils.py +236 -0
- massgen/adapters/utils/tests/__init__.py +0 -0
- massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
- massgen/agent_config.py +329 -55
- massgen/api_params_handler/__init__.py +10 -0
- massgen/api_params_handler/_api_params_handler_base.py +99 -0
- massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
- massgen/api_params_handler/_claude_api_params_handler.py +113 -0
- massgen/api_params_handler/_response_api_params_handler.py +130 -0
- massgen/backend/__init__.py +39 -4
- massgen/backend/azure_openai.py +385 -0
- massgen/backend/base.py +341 -69
- massgen/backend/base_with_mcp.py +1102 -0
- massgen/backend/capabilities.py +386 -0
- massgen/backend/chat_completions.py +577 -130
- massgen/backend/claude.py +1033 -537
- massgen/backend/claude_code.py +1203 -0
- massgen/backend/cli_base.py +209 -0
- massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
- massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
- massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
- massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
- massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
- massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
- massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
- massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
- massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
- massgen/backend/docs/inference_backend.md +257 -0
- massgen/backend/docs/permissions_and_context_files.md +1085 -0
- massgen/backend/external.py +126 -0
- massgen/backend/gemini.py +1850 -241
- massgen/backend/grok.py +40 -156
- massgen/backend/inference.py +156 -0
- massgen/backend/lmstudio.py +171 -0
- massgen/backend/response.py +1095 -322
- massgen/chat_agent.py +131 -113
- massgen/cli.py +1560 -275
- massgen/config_builder.py +2396 -0
- massgen/configs/BACKEND_CONFIGURATION.md +458 -0
- massgen/configs/README.md +559 -216
- massgen/configs/ag2/ag2_case_study.yaml +27 -0
- massgen/configs/ag2/ag2_coder.yaml +34 -0
- massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
- massgen/configs/ag2/ag2_gemini.yaml +27 -0
- massgen/configs/ag2/ag2_groupchat.yaml +108 -0
- massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
- massgen/configs/ag2/ag2_single_agent.yaml +21 -0
- massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
- massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
- massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
- massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
- massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
- massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
- massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
- massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
- massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
- massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
- massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
- massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
- massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
- massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
- massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
- massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
- massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
- massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
- massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
- massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
- massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
- massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
- massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
- massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
- massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
- massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
- massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
- massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
- massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
- massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
- massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
- massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
- massgen/configs/debug/skip_coordination_test.yaml +27 -0
- massgen/configs/debug/test_sdk_migration.yaml +17 -0
- massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
- massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
- massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
- massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
- massgen/configs/providers/claude/claude.yaml +14 -0
- massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
- massgen/configs/providers/local/lmstudio.yaml +11 -0
- massgen/configs/providers/openai/gpt5.yaml +46 -0
- massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
- massgen/configs/providers/others/grok_single_agent.yaml +19 -0
- massgen/configs/providers/others/zai_coding_team.yaml +108 -0
- massgen/configs/providers/others/zai_glm45.yaml +12 -0
- massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
- massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
- massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
- massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
- massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
- massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
- massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
- massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
- massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
- massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
- massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
- massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
- massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
- massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
- massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
- massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
- massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
- massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
- massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
- massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
- massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
- massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
- massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
- massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
- massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
- massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
- massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
- massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
- massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
- massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
- massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
- massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
- massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
- massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
- massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
- massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
- massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
- massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
- massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
- massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
- massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
- massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
- massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
- massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
- massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
- massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
- massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
- massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
- massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
- massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
- massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
- massgen/coordination_tracker.py +708 -0
- massgen/docker/README.md +462 -0
- massgen/filesystem_manager/__init__.py +21 -0
- massgen/filesystem_manager/_base.py +9 -0
- massgen/filesystem_manager/_code_execution_server.py +545 -0
- massgen/filesystem_manager/_docker_manager.py +477 -0
- massgen/filesystem_manager/_file_operation_tracker.py +248 -0
- massgen/filesystem_manager/_filesystem_manager.py +813 -0
- massgen/filesystem_manager/_path_permission_manager.py +1261 -0
- massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
- massgen/formatter/__init__.py +10 -0
- massgen/formatter/_chat_completions_formatter.py +284 -0
- massgen/formatter/_claude_formatter.py +235 -0
- massgen/formatter/_formatter_base.py +156 -0
- massgen/formatter/_response_formatter.py +263 -0
- massgen/frontend/__init__.py +1 -2
- massgen/frontend/coordination_ui.py +471 -286
- massgen/frontend/displays/base_display.py +56 -11
- massgen/frontend/displays/create_coordination_table.py +1956 -0
- massgen/frontend/displays/rich_terminal_display.py +1259 -619
- massgen/frontend/displays/simple_display.py +9 -4
- massgen/frontend/displays/terminal_display.py +27 -68
- massgen/logger_config.py +681 -0
- massgen/mcp_tools/README.md +232 -0
- massgen/mcp_tools/__init__.py +105 -0
- massgen/mcp_tools/backend_utils.py +1035 -0
- massgen/mcp_tools/circuit_breaker.py +195 -0
- massgen/mcp_tools/client.py +894 -0
- massgen/mcp_tools/config_validator.py +138 -0
- massgen/mcp_tools/docs/circuit_breaker.md +646 -0
- massgen/mcp_tools/docs/client.md +950 -0
- massgen/mcp_tools/docs/config_validator.md +478 -0
- massgen/mcp_tools/docs/exceptions.md +1165 -0
- massgen/mcp_tools/docs/security.md +854 -0
- massgen/mcp_tools/exceptions.py +338 -0
- massgen/mcp_tools/hooks.py +212 -0
- massgen/mcp_tools/security.py +780 -0
- massgen/message_templates.py +342 -64
- massgen/orchestrator.py +1515 -241
- massgen/stream_chunk/__init__.py +35 -0
- massgen/stream_chunk/base.py +92 -0
- massgen/stream_chunk/multimodal.py +237 -0
- massgen/stream_chunk/text.py +162 -0
- massgen/tests/mcp_test_server.py +150 -0
- massgen/tests/multi_turn_conversation_design.md +0 -8
- massgen/tests/test_azure_openai_backend.py +156 -0
- massgen/tests/test_backend_capabilities.py +262 -0
- massgen/tests/test_backend_event_loop_all.py +179 -0
- massgen/tests/test_chat_completions_refactor.py +142 -0
- massgen/tests/test_claude_backend.py +15 -28
- massgen/tests/test_claude_code.py +268 -0
- massgen/tests/test_claude_code_context_sharing.py +233 -0
- massgen/tests/test_claude_code_orchestrator.py +175 -0
- massgen/tests/test_cli_backends.py +180 -0
- massgen/tests/test_code_execution.py +679 -0
- massgen/tests/test_external_agent_backend.py +134 -0
- massgen/tests/test_final_presentation_fallback.py +237 -0
- massgen/tests/test_gemini_planning_mode.py +351 -0
- massgen/tests/test_grok_backend.py +7 -10
- massgen/tests/test_http_mcp_server.py +42 -0
- massgen/tests/test_integration_simple.py +198 -0
- massgen/tests/test_mcp_blocking.py +125 -0
- massgen/tests/test_message_context_building.py +29 -47
- massgen/tests/test_orchestrator_final_presentation.py +48 -0
- massgen/tests/test_path_permission_manager.py +2087 -0
- massgen/tests/test_rich_terminal_display.py +14 -13
- massgen/tests/test_timeout.py +133 -0
- massgen/tests/test_v3_3agents.py +11 -12
- massgen/tests/test_v3_simple.py +8 -13
- massgen/tests/test_v3_three_agents.py +11 -18
- massgen/tests/test_v3_two_agents.py +8 -13
- massgen/token_manager/__init__.py +7 -0
- massgen/token_manager/token_manager.py +400 -0
- massgen/utils.py +52 -16
- massgen/v1/agent.py +45 -91
- massgen/v1/agents.py +18 -53
- massgen/v1/backends/gemini.py +50 -153
- massgen/v1/backends/grok.py +21 -54
- massgen/v1/backends/oai.py +39 -111
- massgen/v1/cli.py +36 -93
- massgen/v1/config.py +8 -12
- massgen/v1/logging.py +43 -127
- massgen/v1/main.py +18 -32
- massgen/v1/orchestrator.py +68 -209
- massgen/v1/streaming_display.py +62 -163
- massgen/v1/tools.py +8 -12
- massgen/v1/types.py +9 -23
- massgen/v1/utils.py +5 -23
- massgen-0.1.0.dist-info/METADATA +1245 -0
- massgen-0.1.0.dist-info/RECORD +273 -0
- massgen-0.1.0.dist-info/entry_points.txt +2 -0
- massgen/frontend/logging/__init__.py +0 -9
- massgen/frontend/logging/realtime_logger.py +0 -197
- massgen-0.0.3.dist-info/METADATA +0 -568
- massgen-0.0.3.dist-info/RECORD +0 -76
- massgen-0.0.3.dist-info/entry_points.txt +0 -2
- /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
- {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
- {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
- {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)
|