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,1261 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import fnmatch
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from ..logger_config import logger
|
|
10
|
+
from ..mcp_tools.hooks import HookResult
|
|
11
|
+
from ._base import Permission
|
|
12
|
+
from ._file_operation_tracker import FileOperationTracker
|
|
13
|
+
from ._workspace_tools_server import get_copy_file_pairs
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ManagedPath:
|
|
18
|
+
"""Represents any managed path with its permissions and type."""
|
|
19
|
+
|
|
20
|
+
path: Path
|
|
21
|
+
permission: Permission
|
|
22
|
+
path_type: str # "workspace", "temp_workspace", "context", etc.
|
|
23
|
+
will_be_writable: bool = False # True if this path will become writable for final agent
|
|
24
|
+
is_file: bool = False # True if this is a file-specific context path (not directory)
|
|
25
|
+
protected_paths: List[Path] = None # Paths within this context that are immune from modification/deletion
|
|
26
|
+
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
"""Initialize protected_paths as empty list if None."""
|
|
29
|
+
if self.protected_paths is None:
|
|
30
|
+
self.protected_paths = []
|
|
31
|
+
|
|
32
|
+
def contains(self, check_path: Path) -> bool:
|
|
33
|
+
"""Check if this managed path contains the given path."""
|
|
34
|
+
# If this is a file-specific path, only match the exact file
|
|
35
|
+
if self.is_file:
|
|
36
|
+
return check_path.resolve() == self.path.resolve()
|
|
37
|
+
|
|
38
|
+
# Directory path: check if path is within directory
|
|
39
|
+
try:
|
|
40
|
+
check_path.resolve().relative_to(self.path.resolve())
|
|
41
|
+
return True
|
|
42
|
+
except ValueError:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
def is_protected(self, check_path: Path) -> bool:
|
|
46
|
+
"""Check if a path is in the protected paths list (immune from modification/deletion)."""
|
|
47
|
+
if not self.protected_paths:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
resolved_check = check_path.resolve()
|
|
51
|
+
for protected in self.protected_paths:
|
|
52
|
+
resolved_protected = protected.resolve()
|
|
53
|
+
# Check exact match or if check_path is within protected directory
|
|
54
|
+
if resolved_check == resolved_protected:
|
|
55
|
+
return True
|
|
56
|
+
try:
|
|
57
|
+
resolved_check.relative_to(resolved_protected)
|
|
58
|
+
return True
|
|
59
|
+
except ValueError:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PathPermissionManager:
|
|
66
|
+
"""
|
|
67
|
+
Manages all filesystem paths and implements PreToolUse hook functionality similar to Claude Code,
|
|
68
|
+
allowing us to intercept and validate tool calls based on some predefined rules (here, permissions).
|
|
69
|
+
|
|
70
|
+
This manager handles all types of paths with unified permission control:
|
|
71
|
+
- Workspace paths (typically write)
|
|
72
|
+
- Temporary workspace paths (typically read-only)
|
|
73
|
+
- Context paths (user-specified permissions)
|
|
74
|
+
- Tool call validation (PreToolUse hook)
|
|
75
|
+
- Path access control
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
DEFAULT_EXCLUDED_PATTERNS = [
|
|
79
|
+
".massgen",
|
|
80
|
+
".env",
|
|
81
|
+
".git",
|
|
82
|
+
"node_modules",
|
|
83
|
+
"__pycache__",
|
|
84
|
+
".venv",
|
|
85
|
+
"venv",
|
|
86
|
+
".pytest_cache",
|
|
87
|
+
".mypy_cache",
|
|
88
|
+
".ruff_cache",
|
|
89
|
+
".DS_Store",
|
|
90
|
+
"massgen_logs",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
context_write_access_enabled: bool = False,
|
|
96
|
+
enforce_read_before_delete: bool = True,
|
|
97
|
+
):
|
|
98
|
+
"""
|
|
99
|
+
Initialize path permission manager.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
context_write_access_enabled: Whether write access is enabled for context paths (workspace paths always
|
|
103
|
+
have write access). If False, we change all context paths to read-only. Can be later updated with
|
|
104
|
+
set_context_write_access_enabled(), in which case all existing context paths will be updated
|
|
105
|
+
accordingly so that those that were "write" in YAML become writable again.
|
|
106
|
+
enforce_read_before_delete: Whether to enforce read-before-delete policy for workspace files
|
|
107
|
+
"""
|
|
108
|
+
self.managed_paths: List[ManagedPath] = []
|
|
109
|
+
self.context_write_access_enabled = context_write_access_enabled
|
|
110
|
+
|
|
111
|
+
# Cache for quick permission lookups
|
|
112
|
+
self._permission_cache: Dict[Path, Permission] = {}
|
|
113
|
+
|
|
114
|
+
# File operation tracker for read-before-delete enforcement
|
|
115
|
+
self.file_operation_tracker = FileOperationTracker(enforce_read_before_delete=enforce_read_before_delete)
|
|
116
|
+
|
|
117
|
+
logger.info(
|
|
118
|
+
f"[PathPermissionManager] Initialized with context_write_access_enabled={context_write_access_enabled}, " f"enforce_read_before_delete={enforce_read_before_delete}",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def add_path(self, path: Path, permission: Permission, path_type: str) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Add a managed path.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
path: Path to manage
|
|
127
|
+
permission: Permission level for this path
|
|
128
|
+
path_type: Type of path ("workspace", "temp_workspace", "context", etc.)
|
|
129
|
+
"""
|
|
130
|
+
if not path.exists():
|
|
131
|
+
# For context paths, warn since user should provide existing paths
|
|
132
|
+
# For workspace/temp paths, just debug since they'll be created by orchestrator
|
|
133
|
+
if path_type == "context":
|
|
134
|
+
logger.warning(f"[PathPermissionManager] Context path does not exist: {path}")
|
|
135
|
+
return
|
|
136
|
+
else:
|
|
137
|
+
logger.debug(f"[PathPermissionManager] Path will be created later: {path} ({path_type})")
|
|
138
|
+
|
|
139
|
+
managed_path = ManagedPath(path=path.resolve(), permission=permission, path_type=path_type)
|
|
140
|
+
|
|
141
|
+
self.managed_paths.append(managed_path)
|
|
142
|
+
# Clear cache when adding new paths
|
|
143
|
+
self._permission_cache.clear()
|
|
144
|
+
|
|
145
|
+
logger.info(f"[PathPermissionManager] Added {path_type} path: {path} ({permission.value})")
|
|
146
|
+
|
|
147
|
+
def get_context_paths(self) -> List[Dict[str, str]]:
|
|
148
|
+
"""
|
|
149
|
+
Get context paths in configuration format for system prompts.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of context path dictionaries with path, permission, and will_be_writable flag
|
|
153
|
+
"""
|
|
154
|
+
context_paths = []
|
|
155
|
+
for mp in self.managed_paths:
|
|
156
|
+
if mp.path_type == "context":
|
|
157
|
+
context_paths.append(
|
|
158
|
+
{
|
|
159
|
+
"path": str(mp.path),
|
|
160
|
+
"permission": mp.permission.value,
|
|
161
|
+
"will_be_writable": mp.will_be_writable,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
return context_paths
|
|
165
|
+
|
|
166
|
+
def set_context_write_access_enabled(self, enabled: bool) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Update write access setting for context paths and recalculate their permissions.
|
|
169
|
+
Note: Workspace paths always have write access regardless of this setting.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
enabled: Whether to enable write access for context paths
|
|
173
|
+
"""
|
|
174
|
+
if self.context_write_access_enabled == enabled:
|
|
175
|
+
return # No change needed
|
|
176
|
+
|
|
177
|
+
logger.info(f"[PathPermissionManager] Setting context_write_access_enabled to {enabled}")
|
|
178
|
+
logger.info(f"[PathPermissionManager] Before update: {self.managed_paths=}")
|
|
179
|
+
self.context_write_access_enabled = enabled
|
|
180
|
+
|
|
181
|
+
# Recalculate permissions for existing context paths
|
|
182
|
+
for mp in self.managed_paths:
|
|
183
|
+
if mp.path_type == "context" and mp.will_be_writable:
|
|
184
|
+
# Update permission based on new context_write_access_enabled setting
|
|
185
|
+
if enabled:
|
|
186
|
+
mp.permission = Permission.WRITE
|
|
187
|
+
logger.debug(f"[PathPermissionManager] Enabled write access for {mp.path}")
|
|
188
|
+
else:
|
|
189
|
+
mp.permission = Permission.READ
|
|
190
|
+
logger.debug(f"[PathPermissionManager] Keeping read-only for {mp.path}")
|
|
191
|
+
|
|
192
|
+
logger.info(f"[PathPermissionManager] Updated context path permissions based on context_write_access_enabled={enabled}, now is {self.managed_paths=}")
|
|
193
|
+
|
|
194
|
+
# Clear permission cache to force recalculation
|
|
195
|
+
self._permission_cache.clear()
|
|
196
|
+
|
|
197
|
+
def add_context_paths(self, context_paths: List[Dict[str, Any]]) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Add context paths from configuration.
|
|
200
|
+
|
|
201
|
+
Now supports both files and directories as context paths, with optional protected paths.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
context_paths: List of context path configurations
|
|
205
|
+
Format: [
|
|
206
|
+
{
|
|
207
|
+
"path": "C:/project/src",
|
|
208
|
+
"permission": "write",
|
|
209
|
+
"protected_paths": ["tests/do-not-touch/", "config.yaml"] # Optional
|
|
210
|
+
},
|
|
211
|
+
{"path": "C:/project/logo.png", "permission": "read"}
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
Note: During coordination, all context paths are read-only regardless of YAML settings.
|
|
215
|
+
Only the final agent with context_write_access_enabled=True can write to paths marked as "write".
|
|
216
|
+
Protected paths are ALWAYS read-only and immune from deletion, even if parent has write permission.
|
|
217
|
+
"""
|
|
218
|
+
for config in context_paths:
|
|
219
|
+
path_str = config.get("path", "")
|
|
220
|
+
permission_str = config.get("permission", "read")
|
|
221
|
+
protected_paths_config = config.get("protected_paths", [])
|
|
222
|
+
|
|
223
|
+
if not path_str:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
path = Path(path_str)
|
|
227
|
+
|
|
228
|
+
# Check if path exists and whether it's a file or directory
|
|
229
|
+
if not path.exists():
|
|
230
|
+
logger.warning(f"[PathPermissionManager] Context path does not exist: {path}")
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
is_file = path.is_file()
|
|
234
|
+
|
|
235
|
+
# Parse protected paths - they can be relative to the context path or absolute
|
|
236
|
+
protected_paths = []
|
|
237
|
+
for protected_str in protected_paths_config:
|
|
238
|
+
protected_path = Path(protected_str)
|
|
239
|
+
# If relative, resolve relative to the context path
|
|
240
|
+
if not protected_path.is_absolute():
|
|
241
|
+
if is_file:
|
|
242
|
+
# For file contexts, resolve relative to parent directory
|
|
243
|
+
protected_path = (path.parent / protected_str).resolve()
|
|
244
|
+
else:
|
|
245
|
+
# For directory contexts, resolve relative to the directory
|
|
246
|
+
protected_path = (path / protected_str).resolve()
|
|
247
|
+
else:
|
|
248
|
+
protected_path = protected_path.resolve()
|
|
249
|
+
|
|
250
|
+
# Validate that protected path is actually within the context path
|
|
251
|
+
try:
|
|
252
|
+
if is_file:
|
|
253
|
+
# For file context, protected paths should be in same directory or subdirs
|
|
254
|
+
protected_path.relative_to(path.parent.resolve())
|
|
255
|
+
else:
|
|
256
|
+
# For directory context, protected paths should be within the directory
|
|
257
|
+
protected_path.relative_to(path.resolve())
|
|
258
|
+
protected_paths.append(protected_path)
|
|
259
|
+
logger.info(f"[PathPermissionManager] Added protected path: {protected_path}")
|
|
260
|
+
except ValueError:
|
|
261
|
+
logger.warning(f"[PathPermissionManager] Protected path {protected_path} is not within context path {path}, skipping")
|
|
262
|
+
|
|
263
|
+
# For file context paths, we need to add the parent directory to MCP allowed paths
|
|
264
|
+
# but track only the specific file for permission purposes
|
|
265
|
+
if is_file:
|
|
266
|
+
logger.info(f"[PathPermissionManager] Detected file context path: {path}")
|
|
267
|
+
# Add parent directory to allowed paths (needed for MCP filesystem access)
|
|
268
|
+
parent_dir = path.parent
|
|
269
|
+
if not any(mp.path == parent_dir.resolve() and mp.path_type == "file_context_parent" for mp in self.managed_paths):
|
|
270
|
+
# Add parent as a special type - not directly accessible, just for MCP
|
|
271
|
+
parent_managed = ManagedPath(path=parent_dir.resolve(), permission=Permission.READ, path_type="file_context_parent", will_be_writable=False, is_file=False)
|
|
272
|
+
self.managed_paths.append(parent_managed)
|
|
273
|
+
logger.debug(f"[PathPermissionManager] Added parent directory for file context: {parent_dir}")
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
yaml_permission = Permission(permission_str.lower())
|
|
277
|
+
except ValueError:
|
|
278
|
+
logger.warning(f"[PathPermissionManager] Invalid permission '{permission_str}', using 'read'")
|
|
279
|
+
yaml_permission = Permission.READ
|
|
280
|
+
|
|
281
|
+
# Determine if this path will become writable for final agent
|
|
282
|
+
will_be_writable = yaml_permission == Permission.WRITE
|
|
283
|
+
|
|
284
|
+
# For context paths: only final agent (context_write_access_enabled=True) gets write permissions
|
|
285
|
+
# All coordination agents get read-only access regardless of YAML
|
|
286
|
+
if self.context_write_access_enabled and will_be_writable:
|
|
287
|
+
actual_permission = Permission.WRITE
|
|
288
|
+
logger.debug(f"[PathPermissionManager] Final agent: context path {path} gets write permission")
|
|
289
|
+
else:
|
|
290
|
+
actual_permission = Permission.READ if will_be_writable else yaml_permission
|
|
291
|
+
if will_be_writable:
|
|
292
|
+
logger.debug(f"[PathPermissionManager] Coordination agent: context path {path} read-only (will be writable later)")
|
|
293
|
+
|
|
294
|
+
# Create managed path with will_be_writable, is_file, and protected_paths
|
|
295
|
+
managed_path = ManagedPath(
|
|
296
|
+
path=path.resolve(),
|
|
297
|
+
permission=actual_permission,
|
|
298
|
+
path_type="context",
|
|
299
|
+
will_be_writable=will_be_writable,
|
|
300
|
+
is_file=is_file,
|
|
301
|
+
protected_paths=protected_paths,
|
|
302
|
+
)
|
|
303
|
+
self.managed_paths.append(managed_path)
|
|
304
|
+
self._permission_cache.clear()
|
|
305
|
+
|
|
306
|
+
path_type_str = "file" if is_file else "directory"
|
|
307
|
+
protected_count = len(protected_paths)
|
|
308
|
+
logger.info(f"[PathPermissionManager] Added context {path_type_str}: {path} ({actual_permission.value}, will_be_writable: {will_be_writable}, protected_paths: {protected_count})")
|
|
309
|
+
|
|
310
|
+
def add_previous_turn_paths(self, turn_paths: List[Dict[str, Any]]) -> None:
|
|
311
|
+
"""
|
|
312
|
+
Add previous turn workspace paths for read access.
|
|
313
|
+
These are tracked separately from regular context paths.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
turn_paths: List of turn path configurations
|
|
317
|
+
Format: [{"path": "/path/to/turn_1/workspace", "permission": "read"}, ...]
|
|
318
|
+
"""
|
|
319
|
+
for config in turn_paths:
|
|
320
|
+
path_str = config.get("path", "")
|
|
321
|
+
if not path_str:
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
path = Path(path_str).resolve()
|
|
325
|
+
# Previous turn paths are always read-only
|
|
326
|
+
managed_path = ManagedPath(path=path, permission=Permission.READ, path_type="previous_turn", will_be_writable=False)
|
|
327
|
+
self.managed_paths.append(managed_path)
|
|
328
|
+
self._permission_cache.clear()
|
|
329
|
+
logger.info(f"[PathPermissionManager] Added previous turn path: {path} (read-only)")
|
|
330
|
+
|
|
331
|
+
def _is_excluded_path(self, path: Path) -> bool:
|
|
332
|
+
"""
|
|
333
|
+
Check if a path matches any default excluded patterns.
|
|
334
|
+
|
|
335
|
+
System files like .massgen/, .env, .git/ are always excluded from write access,
|
|
336
|
+
EXCEPT when they are within a managed workspace path (which has explicit permissions).
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
path: Path to check
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
True if path should be excluded from write access
|
|
343
|
+
"""
|
|
344
|
+
# First check if this path is inside a workspace - workspaces override exclusions
|
|
345
|
+
for managed_path in self.managed_paths:
|
|
346
|
+
if managed_path.path_type == "workspace" and managed_path.contains(path):
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
# Now check if path contains any excluded patterns
|
|
350
|
+
parts = path.parts
|
|
351
|
+
for part in parts:
|
|
352
|
+
if part in self.DEFAULT_EXCLUDED_PATTERNS:
|
|
353
|
+
return True
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
def get_permission(self, path: Path) -> Optional[Permission]:
|
|
357
|
+
"""
|
|
358
|
+
Get permission level for a path.
|
|
359
|
+
|
|
360
|
+
Now handles file-specific context paths correctly.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
path: Path to check
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
Permission level or None if path is not in context
|
|
367
|
+
"""
|
|
368
|
+
resolved_path = path.resolve()
|
|
369
|
+
|
|
370
|
+
# Check cache first
|
|
371
|
+
if resolved_path in self._permission_cache:
|
|
372
|
+
logger.debug(f"[PathPermissionManager] Permission cache hit for {resolved_path}: {self._permission_cache[resolved_path].value}")
|
|
373
|
+
return self._permission_cache[resolved_path]
|
|
374
|
+
|
|
375
|
+
# Check if this is an excluded path (always read-only)
|
|
376
|
+
if self._is_excluded_path(resolved_path):
|
|
377
|
+
logger.info(f"[PathPermissionManager] Path {resolved_path} matches excluded pattern, forcing read-only")
|
|
378
|
+
self._permission_cache[resolved_path] = Permission.READ
|
|
379
|
+
return Permission.READ
|
|
380
|
+
|
|
381
|
+
# Check if this path is protected (always read-only, takes precedence over context permissions)
|
|
382
|
+
for managed_path in self.managed_paths:
|
|
383
|
+
if managed_path.contains(resolved_path) and managed_path.is_protected(resolved_path):
|
|
384
|
+
logger.info(f"[PathPermissionManager] Path {resolved_path} is protected, forcing read-only")
|
|
385
|
+
self._permission_cache[resolved_path] = Permission.READ
|
|
386
|
+
return Permission.READ
|
|
387
|
+
|
|
388
|
+
# Find containing managed path with priority system:
|
|
389
|
+
# 1. File-specific paths (is_file=True) get highest priority - exact match only
|
|
390
|
+
# 2. Deeper directory paths get higher priority than shallow ones
|
|
391
|
+
# 3. file_context_parent type is lowest priority (used only for MCP access, not direct access)
|
|
392
|
+
|
|
393
|
+
# Separate file-specific and directory paths
|
|
394
|
+
file_paths = [mp for mp in self.managed_paths if mp.is_file]
|
|
395
|
+
dir_paths = [mp for mp in self.managed_paths if not mp.is_file and mp.path_type != "file_context_parent"]
|
|
396
|
+
# parent_paths are not used in permission checks - they're only for MCP allowed paths
|
|
397
|
+
|
|
398
|
+
# Check file-specific paths first (highest priority, exact match only)
|
|
399
|
+
for managed_path in file_paths:
|
|
400
|
+
if managed_path.contains(resolved_path): # contains() handles exact match for files
|
|
401
|
+
logger.info(
|
|
402
|
+
f"[PathPermissionManager] Found file-specific permission for {resolved_path}: {managed_path.permission.value} "
|
|
403
|
+
f"(from {managed_path.path}, type: {managed_path.path_type}, "
|
|
404
|
+
f"will_be_writable: {managed_path.will_be_writable})",
|
|
405
|
+
)
|
|
406
|
+
self._permission_cache[resolved_path] = managed_path.permission
|
|
407
|
+
return managed_path.permission
|
|
408
|
+
|
|
409
|
+
# Check directory paths (sorted by depth, deeper = higher priority)
|
|
410
|
+
sorted_dir_paths = sorted(dir_paths, key=lambda mp: len(mp.path.parts), reverse=True)
|
|
411
|
+
for managed_path in sorted_dir_paths:
|
|
412
|
+
if managed_path.contains(resolved_path) or managed_path.path == resolved_path:
|
|
413
|
+
logger.info(
|
|
414
|
+
f"[PathPermissionManager] Found permission for {resolved_path}: {managed_path.permission.value} "
|
|
415
|
+
f"(from {managed_path.path}, type: {managed_path.path_type}, "
|
|
416
|
+
f"will_be_writable: {managed_path.will_be_writable})",
|
|
417
|
+
)
|
|
418
|
+
self._permission_cache[resolved_path] = managed_path.permission
|
|
419
|
+
return managed_path.permission
|
|
420
|
+
|
|
421
|
+
# Don't check parent_paths - they're only for MCP allowed paths, not for granting access
|
|
422
|
+
# If we reach here, the path is either in a file_context_parent (denied) or not in any context path
|
|
423
|
+
|
|
424
|
+
logger.debug(f"[PathPermissionManager] No permission found for {resolved_path} in managed paths: {[(str(mp.path), mp.permission.value, mp.path_type) for mp in self.managed_paths]}")
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
async def pre_tool_use_hook(self, tool_name: str, tool_args: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|
428
|
+
"""
|
|
429
|
+
PreToolUse hook to validate tool calls based on permissions.
|
|
430
|
+
|
|
431
|
+
This can be used directly with Claude Code SDK hooks or as validation
|
|
432
|
+
for other backends that need manual tool call filtering.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
tool_name: Name of the tool being called
|
|
436
|
+
tool_args: Arguments passed to the tool
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
Tuple of (allowed: bool, reason: Optional[str])
|
|
440
|
+
- allowed: Whether the tool call should proceed
|
|
441
|
+
- reason: Explanation if blocked (None if allowed)
|
|
442
|
+
"""
|
|
443
|
+
# Track read operations for read-before-delete enforcement
|
|
444
|
+
if self._is_read_tool(tool_name):
|
|
445
|
+
self._track_read_operation(tool_name, tool_args)
|
|
446
|
+
|
|
447
|
+
# Check if this is a write operation using pattern matching
|
|
448
|
+
if self._is_write_tool(tool_name):
|
|
449
|
+
result = self._validate_write_tool(tool_name, tool_args)
|
|
450
|
+
# Track file creation for write tools that succeed
|
|
451
|
+
if result[0] and self._is_create_tool(tool_name):
|
|
452
|
+
self._track_create_operation(tool_name, tool_args)
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
# Check if this is a delete operation
|
|
456
|
+
if self._is_delete_tool(tool_name):
|
|
457
|
+
return self._validate_delete_tool(tool_name, tool_args)
|
|
458
|
+
|
|
459
|
+
# Tools that can potentially modify through commands
|
|
460
|
+
command_tools = {"Bash", "bash", "shell", "exec", "execute_command"}
|
|
461
|
+
|
|
462
|
+
# Check command tools for dangerous operations
|
|
463
|
+
if tool_name in command_tools:
|
|
464
|
+
return self._validate_command_tool(tool_name, tool_args)
|
|
465
|
+
|
|
466
|
+
# For all other tools (including Read, Grep, Glob, list_directory, etc.),
|
|
467
|
+
# validate access to file context paths to prevent sibling file access
|
|
468
|
+
return self._validate_file_context_access(tool_name, tool_args)
|
|
469
|
+
|
|
470
|
+
def _is_write_tool(self, tool_name: str) -> bool:
|
|
471
|
+
"""
|
|
472
|
+
Check if a tool is a write operation using pattern matching.
|
|
473
|
+
|
|
474
|
+
Main Claude Code tools: Bash, Glob, Grep, Read, Edit, MultiEdit, Write, WebFetch, WebSearch
|
|
475
|
+
|
|
476
|
+
This catches various write tools including:
|
|
477
|
+
- Claude Code: Write, Edit, MultiEdit, NotebookEdit, etc.
|
|
478
|
+
- MCP filesystem: write_file, edit_file, create_directory, move_file
|
|
479
|
+
- Any other tools with write/edit/create/move in the name
|
|
480
|
+
|
|
481
|
+
Note: Delete operations are handled separately by _is_delete_tool
|
|
482
|
+
"""
|
|
483
|
+
# Pattern matches tools that modify files/directories (excluding deletes)
|
|
484
|
+
write_patterns = [
|
|
485
|
+
r".*[Ww]rite.*", # Write, write_file, NotebookWrite, etc.
|
|
486
|
+
r".*[Ee]dit.*", # Edit, edit_file, MultiEdit, NotebookEdit, etc.
|
|
487
|
+
r".*[Cc]reate.*", # create_directory, etc.
|
|
488
|
+
r".*[Mm]ove.*", # move_file, etc.
|
|
489
|
+
r".*[Cc]opy.*", # copy_file, copy_files_batch, etc.
|
|
490
|
+
]
|
|
491
|
+
|
|
492
|
+
for pattern in write_patterns:
|
|
493
|
+
if re.match(pattern, tool_name):
|
|
494
|
+
return True
|
|
495
|
+
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
def _is_read_tool(self, tool_name: str) -> bool:
|
|
499
|
+
"""
|
|
500
|
+
Check if a tool is a read operation that should be tracked.
|
|
501
|
+
|
|
502
|
+
Uses substring matching to handle MCP prefixes (e.g., mcp__workspace_tools__compare_files)
|
|
503
|
+
|
|
504
|
+
Tools that read file contents:
|
|
505
|
+
- read/Read: File content reading (matches: Read, read_text_file, read_multimodal_files, etc.)
|
|
506
|
+
- compare_files: File comparison
|
|
507
|
+
- compare_directories: Directory comparison
|
|
508
|
+
"""
|
|
509
|
+
# Use lowercase for case-insensitive matching
|
|
510
|
+
tool_lower = tool_name.lower()
|
|
511
|
+
|
|
512
|
+
# Check if tool name contains any read operation keywords
|
|
513
|
+
read_keywords = [
|
|
514
|
+
# "read", # Matches: read, Read, read_multimodal_files, mcp__filesystem__read_text_file
|
|
515
|
+
"compare_files", # Matches: compare_files
|
|
516
|
+
"compare_directories", # Matches: compare_directories
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
return any(keyword in tool_lower for keyword in read_keywords)
|
|
520
|
+
|
|
521
|
+
def _is_delete_tool(self, tool_name: str) -> bool:
|
|
522
|
+
"""
|
|
523
|
+
Check if a tool is a delete operation.
|
|
524
|
+
|
|
525
|
+
Tools that delete files:
|
|
526
|
+
- delete_file: Single file deletion
|
|
527
|
+
- delete_files_batch: Batch file deletion
|
|
528
|
+
- Any tool with delete/remove in the name
|
|
529
|
+
"""
|
|
530
|
+
delete_patterns = [
|
|
531
|
+
r".*[Dd]elete.*", # delete_file, delete_files_batch, etc.
|
|
532
|
+
r".*[Rr]emove.*", # remove operations
|
|
533
|
+
]
|
|
534
|
+
|
|
535
|
+
for pattern in delete_patterns:
|
|
536
|
+
if re.match(pattern, tool_name):
|
|
537
|
+
return True
|
|
538
|
+
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
def _is_create_tool(self, tool_name: str) -> bool:
|
|
542
|
+
"""
|
|
543
|
+
Check if a tool creates new files (for tracking created files).
|
|
544
|
+
|
|
545
|
+
Tools that create files:
|
|
546
|
+
- Write: Creates new files
|
|
547
|
+
- write_file: MCP filesystem write
|
|
548
|
+
- create_directory: Creates directories
|
|
549
|
+
"""
|
|
550
|
+
create_patterns = [
|
|
551
|
+
r".*[Ww]rite.*", # Write, write_file, etc.
|
|
552
|
+
r".*[Cc]reate.*", # create_directory, etc.
|
|
553
|
+
]
|
|
554
|
+
|
|
555
|
+
for pattern in create_patterns:
|
|
556
|
+
if re.match(pattern, tool_name):
|
|
557
|
+
return True
|
|
558
|
+
|
|
559
|
+
return False
|
|
560
|
+
|
|
561
|
+
def _track_read_operation(self, tool_name: str, tool_args: Dict[str, Any]) -> None:
|
|
562
|
+
"""
|
|
563
|
+
Track files that are read by the agent.
|
|
564
|
+
|
|
565
|
+
Uses substring matching to handle MCP prefixes consistently.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
tool_name: Name of the read tool
|
|
569
|
+
tool_args: Arguments passed to the tool
|
|
570
|
+
"""
|
|
571
|
+
tool_lower = tool_name.lower()
|
|
572
|
+
|
|
573
|
+
# Extract file path(s) from arguments based on tool type
|
|
574
|
+
if "compare_files" in tool_lower:
|
|
575
|
+
# Compare files reads both files
|
|
576
|
+
file1 = tool_args.get("file1") or tool_args.get("file_path1")
|
|
577
|
+
file2 = tool_args.get("file2") or tool_args.get("file_path2")
|
|
578
|
+
if file1:
|
|
579
|
+
path1 = self._resolve_path_against_workspace(file1)
|
|
580
|
+
self.file_operation_tracker.mark_as_read(Path(path1))
|
|
581
|
+
if file2:
|
|
582
|
+
path2 = self._resolve_path_against_workspace(file2)
|
|
583
|
+
self.file_operation_tracker.mark_as_read(Path(path2))
|
|
584
|
+
elif "compare_directories" in tool_lower:
|
|
585
|
+
# Only track if show_content_diff is True (otherwise no content is read)
|
|
586
|
+
if tool_args.get("show_content_diff"):
|
|
587
|
+
# Note: We can't track specific files here, but comparison counts as viewing
|
|
588
|
+
# The validation will happen on delete anyway
|
|
589
|
+
pass
|
|
590
|
+
elif "read_multiple_files" in tool_lower:
|
|
591
|
+
# Read multiple files takes an array of paths
|
|
592
|
+
paths = tool_args.get("paths", [])
|
|
593
|
+
for file_path in paths:
|
|
594
|
+
resolved_path = self._resolve_path_against_workspace(file_path)
|
|
595
|
+
self.file_operation_tracker.mark_as_read(Path(resolved_path))
|
|
596
|
+
else:
|
|
597
|
+
# Single file read operations (Read, read_text_file, read_multimodal_files, etc.)
|
|
598
|
+
file_path = self._extract_file_path(tool_args)
|
|
599
|
+
if file_path:
|
|
600
|
+
resolved_path = self._resolve_path_against_workspace(file_path)
|
|
601
|
+
self.file_operation_tracker.mark_as_read(Path(resolved_path))
|
|
602
|
+
|
|
603
|
+
def _track_create_operation(self, tool_name: str, tool_args: Dict[str, Any]) -> None:
|
|
604
|
+
"""
|
|
605
|
+
Track files that are created by the agent.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
tool_name: Name of the create tool
|
|
609
|
+
tool_args: Arguments passed to the tool
|
|
610
|
+
"""
|
|
611
|
+
file_path = self._extract_file_path(tool_args)
|
|
612
|
+
if file_path:
|
|
613
|
+
resolved_path = self._resolve_path_against_workspace(file_path)
|
|
614
|
+
self.file_operation_tracker.mark_as_created(Path(resolved_path))
|
|
615
|
+
|
|
616
|
+
def _validate_delete_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|
617
|
+
"""
|
|
618
|
+
Validate delete tool operations using read-before-delete policy.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
tool_name: Name of the delete tool
|
|
622
|
+
tool_args: Arguments passed to the tool
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
Tuple of (allowed: bool, reason: Optional[str])
|
|
626
|
+
"""
|
|
627
|
+
# First check normal write permissions
|
|
628
|
+
permission_result = self._validate_write_tool(tool_name, tool_args)
|
|
629
|
+
if not permission_result[0]:
|
|
630
|
+
return permission_result
|
|
631
|
+
|
|
632
|
+
# Special handling for batch delete operations
|
|
633
|
+
if tool_name == "delete_files_batch":
|
|
634
|
+
return self._validate_delete_files_batch(tool_args)
|
|
635
|
+
|
|
636
|
+
# Extract file path
|
|
637
|
+
file_path = self._extract_file_path(tool_args)
|
|
638
|
+
if not file_path:
|
|
639
|
+
# Can't determine path - allow (will fail elsewhere if invalid)
|
|
640
|
+
return (True, None)
|
|
641
|
+
|
|
642
|
+
# Resolve path
|
|
643
|
+
resolved_path = self._resolve_path_against_workspace(file_path)
|
|
644
|
+
path = Path(resolved_path)
|
|
645
|
+
|
|
646
|
+
# Check if it's a directory or file
|
|
647
|
+
if path.is_dir():
|
|
648
|
+
# Check directory deletion
|
|
649
|
+
can_delete, reason = self.file_operation_tracker.can_delete_directory(path)
|
|
650
|
+
if not can_delete:
|
|
651
|
+
return (False, reason)
|
|
652
|
+
else:
|
|
653
|
+
# Check file deletion
|
|
654
|
+
can_delete, reason = self.file_operation_tracker.can_delete(path)
|
|
655
|
+
if not can_delete:
|
|
656
|
+
return (False, reason)
|
|
657
|
+
|
|
658
|
+
return (True, None)
|
|
659
|
+
|
|
660
|
+
def _validate_delete_files_batch(self, tool_args: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|
661
|
+
"""
|
|
662
|
+
Validate batch delete operations by checking all files that would be deleted.
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
tool_args: Arguments for delete_files_batch
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
Tuple of (allowed: bool, reason: Optional[str])
|
|
669
|
+
"""
|
|
670
|
+
try:
|
|
671
|
+
base_path = tool_args.get("base_path")
|
|
672
|
+
include_patterns = tool_args.get("include_patterns") or ["*"]
|
|
673
|
+
exclude_patterns = tool_args.get("exclude_patterns") or []
|
|
674
|
+
|
|
675
|
+
if not base_path:
|
|
676
|
+
return (False, "delete_files_batch requires base_path")
|
|
677
|
+
|
|
678
|
+
# Resolve base path
|
|
679
|
+
resolved_base = self._resolve_path_against_workspace(base_path)
|
|
680
|
+
base = Path(resolved_base)
|
|
681
|
+
|
|
682
|
+
if not base.exists():
|
|
683
|
+
# Path doesn't exist - will fail in actual tool, allow validation to pass
|
|
684
|
+
return (True, None)
|
|
685
|
+
|
|
686
|
+
# Collect files that would be deleted
|
|
687
|
+
unread_files = []
|
|
688
|
+
for item in base.rglob("*"):
|
|
689
|
+
if not item.is_file():
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
# Get relative path from base
|
|
693
|
+
rel_path = item.relative_to(base)
|
|
694
|
+
rel_path_str = str(rel_path)
|
|
695
|
+
|
|
696
|
+
# Check include patterns
|
|
697
|
+
included = any(fnmatch.fnmatch(rel_path_str, pattern) for pattern in include_patterns)
|
|
698
|
+
if not included:
|
|
699
|
+
continue
|
|
700
|
+
|
|
701
|
+
# Check exclude patterns
|
|
702
|
+
excluded = any(fnmatch.fnmatch(rel_path_str, pattern) for pattern in exclude_patterns)
|
|
703
|
+
if excluded:
|
|
704
|
+
continue
|
|
705
|
+
|
|
706
|
+
# Check if file was read
|
|
707
|
+
if not self.file_operation_tracker.was_read(item):
|
|
708
|
+
unread_files.append(rel_path_str)
|
|
709
|
+
|
|
710
|
+
if unread_files:
|
|
711
|
+
# Limit to first 3 unread files for readable error message
|
|
712
|
+
example_files = unread_files[:3]
|
|
713
|
+
suffix = f" (and {len(unread_files) - 3} more)" if len(unread_files) > 3 else ""
|
|
714
|
+
reason = (
|
|
715
|
+
f"Cannot delete {len(unread_files)} unread file(s). " f"Examples: {', '.join(example_files)}{suffix}. " f"Please read files before deletion using Read or read_multimodal_files."
|
|
716
|
+
)
|
|
717
|
+
logger.info(f"[PathPermissionManager] Blocking batch delete: {reason}")
|
|
718
|
+
return (False, reason)
|
|
719
|
+
|
|
720
|
+
return (True, None)
|
|
721
|
+
|
|
722
|
+
except Exception as e:
|
|
723
|
+
logger.error(f"[PathPermissionManager] Error validating batch delete: {e}")
|
|
724
|
+
return (False, f"Batch delete validation failed: {e}")
|
|
725
|
+
|
|
726
|
+
def _is_path_within_allowed_directories(self, path: Path) -> bool:
|
|
727
|
+
"""
|
|
728
|
+
Check if a path is within any allowed directory (workspace or context paths).
|
|
729
|
+
|
|
730
|
+
This enforces directory boundaries - paths outside managed directories are not allowed.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
path: Path to check
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
True if path is within allowed directories, False otherwise
|
|
737
|
+
"""
|
|
738
|
+
resolved_path = path.resolve()
|
|
739
|
+
|
|
740
|
+
# Check if path is within any managed path (excluding file_context_parent)
|
|
741
|
+
for managed_path in self.managed_paths:
|
|
742
|
+
# file_context_parent paths don't grant access, only their specific files do
|
|
743
|
+
if managed_path.path_type == "file_context_parent":
|
|
744
|
+
continue
|
|
745
|
+
|
|
746
|
+
if managed_path.contains(resolved_path) or managed_path.path == resolved_path:
|
|
747
|
+
return True
|
|
748
|
+
|
|
749
|
+
return False
|
|
750
|
+
|
|
751
|
+
def _validate_file_context_access(self, tool_name: str, tool_args: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|
752
|
+
"""
|
|
753
|
+
Validate access for all file operations - enforces directory boundaries and permissions.
|
|
754
|
+
|
|
755
|
+
This method ensures that:
|
|
756
|
+
1. ALL file operations are restricted to workspace + context paths (directory boundary)
|
|
757
|
+
2. Read/write permissions are enforced within allowed directories
|
|
758
|
+
3. Sibling file access is prevented for file-specific context paths
|
|
759
|
+
|
|
760
|
+
Args:
|
|
761
|
+
tool_name: Name of the tool being called
|
|
762
|
+
tool_args: Arguments passed to the tool
|
|
763
|
+
|
|
764
|
+
Returns:
|
|
765
|
+
Tuple of (allowed: bool, reason: Optional[str])
|
|
766
|
+
"""
|
|
767
|
+
# Extract file path from arguments
|
|
768
|
+
file_path = self._extract_file_path(tool_args)
|
|
769
|
+
if not file_path:
|
|
770
|
+
# Can't determine path - allow it (tool may not access files, or uses different args)
|
|
771
|
+
return (True, None)
|
|
772
|
+
|
|
773
|
+
# Resolve relative paths against workspace
|
|
774
|
+
file_path = self._resolve_path_against_workspace(file_path)
|
|
775
|
+
path = Path(file_path).resolve()
|
|
776
|
+
|
|
777
|
+
# SECURITY: Check directory boundary - path must be within allowed directories
|
|
778
|
+
if not self._is_path_within_allowed_directories(path):
|
|
779
|
+
logger.warning(f"[PathPermissionManager] BLOCKED: '{tool_name}' attempted to access path outside allowed directories: {path}")
|
|
780
|
+
return (False, f"Access denied: '{path}' is outside allowed directories. Only workspace and context paths are accessible.")
|
|
781
|
+
|
|
782
|
+
permission = self.get_permission(path)
|
|
783
|
+
logger.debug(f"[PathPermissionManager] Validating '{tool_name}' on path: {path} with permission: {permission}")
|
|
784
|
+
|
|
785
|
+
# If permission is None but we're within allowed directories, check for file_context_parent edge case
|
|
786
|
+
if permission is None:
|
|
787
|
+
parent_paths = [mp for mp in self.managed_paths if mp.path_type == "file_context_parent"]
|
|
788
|
+
for parent_mp in parent_paths:
|
|
789
|
+
if parent_mp.contains(path):
|
|
790
|
+
# Path is in a file context parent dir, but not the specific file
|
|
791
|
+
return (False, f"Access denied: '{path}' is not an explicitly allowed file in this directory")
|
|
792
|
+
# Within allowed directories and has no specific restrictions - allow
|
|
793
|
+
return (True, None)
|
|
794
|
+
|
|
795
|
+
# Has explicit permission - allow
|
|
796
|
+
return (True, None)
|
|
797
|
+
|
|
798
|
+
def _validate_write_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|
799
|
+
"""Validate write tool access."""
|
|
800
|
+
# Special handling for copy_files_batch - validate all destination paths after globbing
|
|
801
|
+
if tool_name == "copy_files_batch":
|
|
802
|
+
return self._validate_copy_files_batch(tool_args)
|
|
803
|
+
|
|
804
|
+
# Extract file path from arguments
|
|
805
|
+
file_path = self._extract_file_path(tool_args)
|
|
806
|
+
if not file_path:
|
|
807
|
+
# Can't determine path - allow it (likely workspace or other non-context path)
|
|
808
|
+
return (True, None)
|
|
809
|
+
|
|
810
|
+
# Resolve relative paths against workspace
|
|
811
|
+
file_path = self._resolve_path_against_workspace(file_path)
|
|
812
|
+
path = Path(file_path).resolve()
|
|
813
|
+
permission = self.get_permission(path)
|
|
814
|
+
logger.debug(f"[PathPermissionManager] Validating write tool '{tool_name}' for path: {path} with permission: {permission}")
|
|
815
|
+
|
|
816
|
+
# No permission means not in context paths (workspace paths are always allowed)
|
|
817
|
+
# IMPORTANT: Check if this path is in a file_context_parent directory
|
|
818
|
+
# If so, access should be denied (only the specific file has access, not siblings)
|
|
819
|
+
if permission is None:
|
|
820
|
+
# Check if path is within a file_context_parent directory
|
|
821
|
+
parent_paths = [mp for mp in self.managed_paths if mp.path_type == "file_context_parent"]
|
|
822
|
+
for parent_mp in parent_paths:
|
|
823
|
+
if parent_mp.contains(path):
|
|
824
|
+
# Path is in a file context parent dir, but not the specific file
|
|
825
|
+
# Deny access to prevent sibling file access
|
|
826
|
+
return (False, f"Access denied: '{path}' is not an explicitly allowed file in this directory")
|
|
827
|
+
# Not in any managed paths - allow (likely workspace or other valid path)
|
|
828
|
+
return (True, None)
|
|
829
|
+
|
|
830
|
+
# Check write permission (permission is already set correctly based on context_write_access_enabled)
|
|
831
|
+
if permission == Permission.WRITE:
|
|
832
|
+
return (True, None)
|
|
833
|
+
else:
|
|
834
|
+
return (False, f"No write permission for '{path}' (read-only context path)")
|
|
835
|
+
|
|
836
|
+
def _resolve_path_against_workspace(self, path_str: str) -> str:
|
|
837
|
+
"""
|
|
838
|
+
Resolve a path string against the workspace directory if it's relative.
|
|
839
|
+
|
|
840
|
+
When MCP servers run with cwd set to workspace, they resolve relative paths
|
|
841
|
+
against the workspace. This function does the same for validation purposes.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
path_str: Path string that may be relative or absolute
|
|
845
|
+
|
|
846
|
+
Returns:
|
|
847
|
+
Absolute path string (resolved against workspace if relative)
|
|
848
|
+
"""
|
|
849
|
+
if not path_str:
|
|
850
|
+
return path_str
|
|
851
|
+
|
|
852
|
+
# Handle tilde expansion (home directory)
|
|
853
|
+
if path_str.startswith("~"):
|
|
854
|
+
path = Path(path_str).expanduser()
|
|
855
|
+
return str(path)
|
|
856
|
+
|
|
857
|
+
path = Path(path_str)
|
|
858
|
+
if path.is_absolute():
|
|
859
|
+
return path_str
|
|
860
|
+
|
|
861
|
+
# Relative path - resolve against workspace
|
|
862
|
+
mcp_paths = self.get_mcp_filesystem_paths()
|
|
863
|
+
if mcp_paths:
|
|
864
|
+
workspace_path = Path(mcp_paths[0]) # First path is always workspace
|
|
865
|
+
resolved = workspace_path / path_str
|
|
866
|
+
logger.debug(f"[PathPermissionManager] Resolved relative path '{path_str}' to '{resolved}'")
|
|
867
|
+
return str(resolved)
|
|
868
|
+
|
|
869
|
+
return path_str
|
|
870
|
+
|
|
871
|
+
def _validate_copy_files_batch(self, tool_args: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|
872
|
+
"""Validate copy_files_batch by checking all destination paths after globbing."""
|
|
873
|
+
try:
|
|
874
|
+
logger.debug(f"[PathPermissionManager] copy_files_batch validation - context_write_access_enabled: {self.context_write_access_enabled}")
|
|
875
|
+
# Get all the file pairs that would be copied
|
|
876
|
+
source_base_path = tool_args.get("source_base_path")
|
|
877
|
+
destination_base_path = tool_args.get("destination_base_path", "")
|
|
878
|
+
include_patterns = tool_args.get("include_patterns")
|
|
879
|
+
exclude_patterns = tool_args.get("exclude_patterns")
|
|
880
|
+
|
|
881
|
+
if not source_base_path:
|
|
882
|
+
return (False, "copy_files_batch requires source_base_path")
|
|
883
|
+
|
|
884
|
+
# Resolve relative destination path against workspace
|
|
885
|
+
destination_base_path = self._resolve_path_against_workspace(destination_base_path)
|
|
886
|
+
|
|
887
|
+
# Get all file pairs (this also validates path restrictions)
|
|
888
|
+
file_pairs = get_copy_file_pairs(self.get_mcp_filesystem_paths(), source_base_path, destination_base_path, include_patterns, exclude_patterns)
|
|
889
|
+
|
|
890
|
+
# Check permissions for each destination path
|
|
891
|
+
blocked_paths = []
|
|
892
|
+
for source_file, dest_file in file_pairs:
|
|
893
|
+
permission = self.get_permission(dest_file)
|
|
894
|
+
logger.debug(f"[PathPermissionManager] copy_files_batch checking dest: {dest_file}, permission: {permission}")
|
|
895
|
+
if permission == Permission.READ:
|
|
896
|
+
blocked_paths.append(str(dest_file))
|
|
897
|
+
|
|
898
|
+
if blocked_paths:
|
|
899
|
+
# Limit to first few blocked paths for readable error message
|
|
900
|
+
example_paths = blocked_paths[:3]
|
|
901
|
+
suffix = f" (and {len(blocked_paths) - 3} more)" if len(blocked_paths) > 3 else ""
|
|
902
|
+
return (False, f"No write permission for destination paths: {', '.join(example_paths)}{suffix}")
|
|
903
|
+
|
|
904
|
+
return (True, None)
|
|
905
|
+
|
|
906
|
+
except Exception as e:
|
|
907
|
+
return (False, f"copy_files_batch validation failed: {e}")
|
|
908
|
+
|
|
909
|
+
def _validate_command_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
|
910
|
+
"""Validate command tool access.
|
|
911
|
+
|
|
912
|
+
As of v0.0.20, only Claude Code supports execution.
|
|
913
|
+
|
|
914
|
+
For Claude Code: Validates directory boundaries for all paths in Bash commands.
|
|
915
|
+
This prevents access to paths outside workspace + context paths.
|
|
916
|
+
|
|
917
|
+
"""
|
|
918
|
+
# Extract command from arguments
|
|
919
|
+
command = tool_args.get("command", "") or tool_args.get("cmd", "")
|
|
920
|
+
|
|
921
|
+
# Dangerous patterns to block
|
|
922
|
+
dangerous_patterns = [
|
|
923
|
+
"rm ",
|
|
924
|
+
"rm -",
|
|
925
|
+
"rmdir",
|
|
926
|
+
"del ",
|
|
927
|
+
"sudo ",
|
|
928
|
+
"su ",
|
|
929
|
+
"chmod ",
|
|
930
|
+
"chown ",
|
|
931
|
+
"format ",
|
|
932
|
+
"fdisk",
|
|
933
|
+
"mkfs",
|
|
934
|
+
]
|
|
935
|
+
|
|
936
|
+
# File modification patterns to check when write access disabled
|
|
937
|
+
write_patterns = [
|
|
938
|
+
">",
|
|
939
|
+
">>", # Redirects
|
|
940
|
+
"mv ",
|
|
941
|
+
"move ",
|
|
942
|
+
"cp ",
|
|
943
|
+
"copy ",
|
|
944
|
+
"touch ",
|
|
945
|
+
"mkdir ",
|
|
946
|
+
"echo ",
|
|
947
|
+
"sed -i",
|
|
948
|
+
"perl -i", # In-place edits
|
|
949
|
+
]
|
|
950
|
+
|
|
951
|
+
for pattern in write_patterns:
|
|
952
|
+
if pattern in command:
|
|
953
|
+
# Try to extract the target file
|
|
954
|
+
target_file = self._extract_file_from_command(command, pattern)
|
|
955
|
+
if target_file:
|
|
956
|
+
path = Path(target_file).resolve()
|
|
957
|
+
permission = self.get_permission(path)
|
|
958
|
+
if permission and permission == Permission.READ:
|
|
959
|
+
return (False, f"Command would modify read-only context path: {path}")
|
|
960
|
+
|
|
961
|
+
# Always block dangerous commands
|
|
962
|
+
for pattern in dangerous_patterns:
|
|
963
|
+
if pattern in command.lower():
|
|
964
|
+
return (False, f"Dangerous command pattern '{pattern}' is not allowed")
|
|
965
|
+
|
|
966
|
+
# Block command injection techniques that can bypass path validation
|
|
967
|
+
# Environment variables: $HOME, $TMPDIR, ${VAR}, etc.
|
|
968
|
+
if "$" in command:
|
|
969
|
+
# Allow common safe variables like $?, $#, $$, $0-$9
|
|
970
|
+
# Block everything else including $HOME, $USER, $(command), ${var}
|
|
971
|
+
safe_vars = ["$?", "$#", "$$"]
|
|
972
|
+
has_unsafe_var = False
|
|
973
|
+
if "$(" in command or "${" in command:
|
|
974
|
+
has_unsafe_var = True
|
|
975
|
+
elif any(c in command for c in ["$HOME", "$USER", "$TMPDIR", "$PWD", "$OLDPWD", "$PATH"]):
|
|
976
|
+
has_unsafe_var = True
|
|
977
|
+
else:
|
|
978
|
+
# Check for $VAR pattern (dollar followed by letters)
|
|
979
|
+
import re
|
|
980
|
+
|
|
981
|
+
if re.search(r"\$[A-Za-z_][A-Za-z0-9_]*", command):
|
|
982
|
+
# Allow only the safe ones
|
|
983
|
+
for safe in safe_vars:
|
|
984
|
+
command = command.replace(safe, "")
|
|
985
|
+
if re.search(r"\$[A-Za-z_][A-Za-z0-9_]*", command):
|
|
986
|
+
has_unsafe_var = True
|
|
987
|
+
|
|
988
|
+
if has_unsafe_var:
|
|
989
|
+
return (False, "Environment variables in Bash commands are not allowed (security risk: can reference paths outside workspace)")
|
|
990
|
+
|
|
991
|
+
# Block command substitution (can execute arbitrary commands and use output as paths)
|
|
992
|
+
if "`" in command:
|
|
993
|
+
return (False, "Backtick command substitution is not allowed (security risk)")
|
|
994
|
+
|
|
995
|
+
# Block process substitution (can access arbitrary paths)
|
|
996
|
+
if "<(" in command or ">(" in command:
|
|
997
|
+
return (False, "Process substitution is not allowed (security risk)")
|
|
998
|
+
|
|
999
|
+
# CLAUDE CODE SPECIFIC: Extract and validate all paths (absolute and relative) in the command
|
|
1000
|
+
# This prevents Bash commands from accessing paths outside allowed directories (e.g., ../../)
|
|
1001
|
+
paths = self._extract_paths_from_command(command)
|
|
1002
|
+
for path_str in paths:
|
|
1003
|
+
try:
|
|
1004
|
+
# Resolve relative paths against workspace
|
|
1005
|
+
resolved_path_str = self._resolve_path_against_workspace(path_str)
|
|
1006
|
+
path = Path(resolved_path_str).resolve()
|
|
1007
|
+
|
|
1008
|
+
# Check if this path is within allowed directories
|
|
1009
|
+
if not self._is_path_within_allowed_directories(path):
|
|
1010
|
+
logger.warning(f"[PathPermissionManager] BLOCKED Bash command accessing path outside allowed directories: {path} (from: {path_str})")
|
|
1011
|
+
return (False, f"Access denied: Bash command references '{path_str}' which resolves to '{path}' outside allowed directories")
|
|
1012
|
+
except Exception as e:
|
|
1013
|
+
logger.debug(f"[PathPermissionManager] Could not validate path '{path_str}' in Bash command: {e}")
|
|
1014
|
+
# If we can't parse it, allow it - might not be a real path
|
|
1015
|
+
continue
|
|
1016
|
+
|
|
1017
|
+
return (True, None)
|
|
1018
|
+
|
|
1019
|
+
def _extract_file_path(self, tool_args: Dict[str, Any]) -> Optional[str]:
|
|
1020
|
+
"""Extract file path from tool arguments."""
|
|
1021
|
+
# Common argument names for file paths:
|
|
1022
|
+
# - Claude Code: file_path, notebook_path
|
|
1023
|
+
# - MCP filesystem: path, source, destination
|
|
1024
|
+
# - Workspace copy: source_path, destination_path, source_base_path, destination_base_path
|
|
1025
|
+
path_keys = [
|
|
1026
|
+
"file_path",
|
|
1027
|
+
"path",
|
|
1028
|
+
"filename",
|
|
1029
|
+
"file",
|
|
1030
|
+
"notebook_path",
|
|
1031
|
+
"target",
|
|
1032
|
+
"destination",
|
|
1033
|
+
"destination_path",
|
|
1034
|
+
"destination_base_path",
|
|
1035
|
+
] # source paths should NOT be checked bc they are always read from, not written to
|
|
1036
|
+
|
|
1037
|
+
for key in path_keys:
|
|
1038
|
+
if key in tool_args:
|
|
1039
|
+
return tool_args[key]
|
|
1040
|
+
|
|
1041
|
+
return None
|
|
1042
|
+
|
|
1043
|
+
def _extract_file_from_command(self, command: str, pattern: str) -> Optional[str]:
|
|
1044
|
+
"""Try to extract target file from a command string."""
|
|
1045
|
+
# This is a simplified extraction - could be enhanced
|
|
1046
|
+
# For redirects like > or >>
|
|
1047
|
+
if pattern in [">", ">>"]:
|
|
1048
|
+
parts = command.split(pattern)
|
|
1049
|
+
if len(parts) > 1:
|
|
1050
|
+
# Get the part after redirect, strip whitespace and quotes
|
|
1051
|
+
target = parts[1].strip().split()[0] if parts[1].strip() else None
|
|
1052
|
+
if target:
|
|
1053
|
+
return target.strip("\"'")
|
|
1054
|
+
|
|
1055
|
+
# For commands like mv, cp
|
|
1056
|
+
if pattern in ["mv ", "cp ", "move ", "copy "]:
|
|
1057
|
+
parts = command.split()
|
|
1058
|
+
try:
|
|
1059
|
+
idx = parts.index(pattern.strip())
|
|
1060
|
+
if idx + 2 < len(parts):
|
|
1061
|
+
# The second argument is typically the destination
|
|
1062
|
+
return parts[idx + 2]
|
|
1063
|
+
except (ValueError, IndexError):
|
|
1064
|
+
pass
|
|
1065
|
+
|
|
1066
|
+
# For simple commands like touch, mkdir, echo (first argument after command)
|
|
1067
|
+
if pattern in ["touch ", "mkdir ", "echo "]:
|
|
1068
|
+
parts = command.split()
|
|
1069
|
+
try:
|
|
1070
|
+
idx = parts.index(pattern.strip())
|
|
1071
|
+
if idx + 1 < len(parts):
|
|
1072
|
+
# The first argument is the target
|
|
1073
|
+
return parts[idx + 1].strip("\"'")
|
|
1074
|
+
except (ValueError, IndexError):
|
|
1075
|
+
pass
|
|
1076
|
+
|
|
1077
|
+
return None
|
|
1078
|
+
|
|
1079
|
+
def _extract_paths_from_command(self, command: str) -> List[str]:
|
|
1080
|
+
"""
|
|
1081
|
+
Extract all potential file/directory paths from a Bash command for validation.
|
|
1082
|
+
|
|
1083
|
+
This is Claude Code specific - extracts paths to validate directory boundaries.
|
|
1084
|
+
Looks for both absolute paths (starting with /) and relative paths (including ../).
|
|
1085
|
+
|
|
1086
|
+
Args:
|
|
1087
|
+
command: Bash command string
|
|
1088
|
+
|
|
1089
|
+
Returns:
|
|
1090
|
+
List of path strings found in the command
|
|
1091
|
+
"""
|
|
1092
|
+
import shlex
|
|
1093
|
+
|
|
1094
|
+
paths = []
|
|
1095
|
+
|
|
1096
|
+
try:
|
|
1097
|
+
# Split command into tokens, handling quoted strings properly
|
|
1098
|
+
tokens = shlex.split(command)
|
|
1099
|
+
except ValueError:
|
|
1100
|
+
# If shlex fails (malformed quotes), fall back to simple split
|
|
1101
|
+
tokens = command.split()
|
|
1102
|
+
|
|
1103
|
+
for token in tokens:
|
|
1104
|
+
# Strip common decorations
|
|
1105
|
+
cleaned = token.strip("\"'").strip()
|
|
1106
|
+
|
|
1107
|
+
# Skip obvious non-paths (flags, empty strings, etc.)
|
|
1108
|
+
if not cleaned:
|
|
1109
|
+
continue
|
|
1110
|
+
if cleaned.startswith("-"): # Flags like -la, --help
|
|
1111
|
+
continue
|
|
1112
|
+
if cleaned in ["&&", "||", "|", ";", ">"]: # Operators
|
|
1113
|
+
continue
|
|
1114
|
+
|
|
1115
|
+
# Check if it looks like a path:
|
|
1116
|
+
# 1. Absolute paths (starts with /)
|
|
1117
|
+
# 2. Home directory paths (starts with ~ - including single char ~)
|
|
1118
|
+
# 3. Relative parent paths (starts with ../ or is ..)
|
|
1119
|
+
# 4. Relative current paths (starts with ./)
|
|
1120
|
+
if cleaned.startswith("/") or cleaned.startswith("~") or cleaned.startswith("../") or cleaned == ".." or cleaned.startswith("./"):
|
|
1121
|
+
# Handle wildcards - extract base directory before wildcard
|
|
1122
|
+
if "*" in cleaned or "?" in cleaned or "[" in cleaned:
|
|
1123
|
+
# Split on wildcard and take the directory part
|
|
1124
|
+
base = cleaned.split("*")[0].split("?")[0].split("[")[0]
|
|
1125
|
+
# If base ends with /, remove it
|
|
1126
|
+
if base.endswith("/"):
|
|
1127
|
+
base = base[:-1]
|
|
1128
|
+
# Validate the base directory instead
|
|
1129
|
+
if base:
|
|
1130
|
+
paths.append(base)
|
|
1131
|
+
else:
|
|
1132
|
+
paths.append(cleaned)
|
|
1133
|
+
|
|
1134
|
+
return paths
|
|
1135
|
+
|
|
1136
|
+
def get_accessible_paths(self) -> List[Path]:
|
|
1137
|
+
"""Get list of all accessible paths."""
|
|
1138
|
+
return [path.path for path in self.managed_paths]
|
|
1139
|
+
|
|
1140
|
+
def get_mcp_filesystem_paths(self) -> List[str]:
|
|
1141
|
+
"""
|
|
1142
|
+
Get all managed paths for MCP filesystem server configuration. Workspace path will be first.
|
|
1143
|
+
|
|
1144
|
+
Only returns directories, as MCP filesystem server cannot accept file paths as arguments.
|
|
1145
|
+
For file context paths, the parent directory is already added with path_type="file_context_parent".
|
|
1146
|
+
|
|
1147
|
+
Returns:
|
|
1148
|
+
List of directory path strings to include in MCP filesystem server args
|
|
1149
|
+
"""
|
|
1150
|
+
# Only include directories - exclude file-type managed paths (is_file=True)
|
|
1151
|
+
# The parent directory for file contexts is already added separately
|
|
1152
|
+
workspace_paths = [str(mp.path) for mp in self.managed_paths if mp.path_type == "workspace"]
|
|
1153
|
+
other_paths = [str(mp.path) for mp in self.managed_paths if mp.path_type != "workspace" and not mp.is_file]
|
|
1154
|
+
out = workspace_paths + other_paths
|
|
1155
|
+
return out
|
|
1156
|
+
|
|
1157
|
+
def get_permission_summary(self) -> str:
|
|
1158
|
+
"""Get a human-readable summary of permissions."""
|
|
1159
|
+
if not self.managed_paths:
|
|
1160
|
+
return "No managed paths configured"
|
|
1161
|
+
|
|
1162
|
+
lines = [f"Managed paths ({len(self.managed_paths)} total):"]
|
|
1163
|
+
for managed_path in self.managed_paths:
|
|
1164
|
+
emoji = "📝" if managed_path.permission == Permission.WRITE else "👁️"
|
|
1165
|
+
lines.append(f" {emoji} {managed_path.path} ({managed_path.permission.value}, {managed_path.path_type})")
|
|
1166
|
+
|
|
1167
|
+
return "\n".join(lines)
|
|
1168
|
+
|
|
1169
|
+
async def validate_context_access(self, input_data: Dict[str, Any], tool_use_id: Optional[str], context: Any) -> Dict[str, Any]: # HookContext from claude_code_sdk
|
|
1170
|
+
"""
|
|
1171
|
+
Claude Code SDK compatible hook function for PreToolUse.
|
|
1172
|
+
|
|
1173
|
+
Args:
|
|
1174
|
+
input_data: Tool input data with 'tool_name' and 'tool_input'
|
|
1175
|
+
tool_use_id: Tool use identifier
|
|
1176
|
+
context: HookContext from claude_code_sdk
|
|
1177
|
+
|
|
1178
|
+
Returns:
|
|
1179
|
+
Hook response dict with permission decision
|
|
1180
|
+
"""
|
|
1181
|
+
logger.info(f"[PathPermissionManager] PreToolUse hook called for tool_use_id={tool_use_id}, input_data={input_data}")
|
|
1182
|
+
|
|
1183
|
+
tool_name = input_data.get("tool_name", "")
|
|
1184
|
+
tool_input = input_data.get("tool_input", {})
|
|
1185
|
+
|
|
1186
|
+
# Use our existing validation logic
|
|
1187
|
+
allowed, reason = await self.pre_tool_use_hook(tool_name, tool_input)
|
|
1188
|
+
|
|
1189
|
+
if not allowed:
|
|
1190
|
+
logger.warning(f"[PathPermissionManager] Blocked {tool_name}: {reason}")
|
|
1191
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason or "Access denied based on context path permissions"}}
|
|
1192
|
+
|
|
1193
|
+
return {} # Empty response means allow
|
|
1194
|
+
|
|
1195
|
+
def get_claude_code_hooks_config(self) -> Dict[str, Any]:
|
|
1196
|
+
"""
|
|
1197
|
+
Get Claude Agent SDK hooks configuration.
|
|
1198
|
+
|
|
1199
|
+
Returns:
|
|
1200
|
+
Hooks configuration dict for ClaudeAgentOptions
|
|
1201
|
+
"""
|
|
1202
|
+
if not self.managed_paths:
|
|
1203
|
+
return {}
|
|
1204
|
+
|
|
1205
|
+
# Import here to avoid dependency issues if SDK not available
|
|
1206
|
+
try:
|
|
1207
|
+
from claude_agent_sdk import HookMatcher
|
|
1208
|
+
except ImportError:
|
|
1209
|
+
logger.warning("[PathPermissionManager] claude_agent_sdk not available, hooks disabled")
|
|
1210
|
+
return {}
|
|
1211
|
+
|
|
1212
|
+
return {
|
|
1213
|
+
"PreToolUse": [
|
|
1214
|
+
# Apply directory boundary + permission validation to ALL file-access tools
|
|
1215
|
+
# This ensures Claude cannot access files outside workspace + context paths
|
|
1216
|
+
HookMatcher(matcher="Read", hooks=[self.validate_context_access]),
|
|
1217
|
+
HookMatcher(matcher="Write", hooks=[self.validate_context_access]),
|
|
1218
|
+
HookMatcher(matcher="Edit", hooks=[self.validate_context_access]),
|
|
1219
|
+
HookMatcher(matcher="MultiEdit", hooks=[self.validate_context_access]),
|
|
1220
|
+
HookMatcher(matcher="NotebookEdit", hooks=[self.validate_context_access]),
|
|
1221
|
+
HookMatcher(matcher="Grep", hooks=[self.validate_context_access]),
|
|
1222
|
+
HookMatcher(matcher="Glob", hooks=[self.validate_context_access]),
|
|
1223
|
+
HookMatcher(matcher="LS", hooks=[self.validate_context_access]),
|
|
1224
|
+
HookMatcher(matcher="Bash", hooks=[self.validate_context_access]),
|
|
1225
|
+
],
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
# Hook implementation for PathPermissionManager
|
|
1230
|
+
class PathPermissionManagerHook:
|
|
1231
|
+
"""
|
|
1232
|
+
Simple FunctionHook implementation that uses PathPermissionManager.
|
|
1233
|
+
|
|
1234
|
+
This bridges the PathPermissionManager to the FunctionHook system.
|
|
1235
|
+
"""
|
|
1236
|
+
|
|
1237
|
+
def __init__(self, path_permission_manager):
|
|
1238
|
+
self.name = "path_permission_hook"
|
|
1239
|
+
self.path_permission_manager = path_permission_manager
|
|
1240
|
+
|
|
1241
|
+
async def execute(self, function_name: str, arguments: str, context=None, **kwargs):
|
|
1242
|
+
"""Execute permission check using PathPermissionManager."""
|
|
1243
|
+
try:
|
|
1244
|
+
try:
|
|
1245
|
+
tool_args = json.loads(arguments) if arguments else {}
|
|
1246
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
1247
|
+
logger.warning(f"[PathPermissionManagerHook] Invalid JSON arguments for {function_name}: {e}")
|
|
1248
|
+
tool_args = {}
|
|
1249
|
+
|
|
1250
|
+
# Call the existing pre_tool_use_hook method
|
|
1251
|
+
allowed, reason = await self.path_permission_manager.pre_tool_use_hook(function_name, tool_args)
|
|
1252
|
+
|
|
1253
|
+
if not allowed:
|
|
1254
|
+
logger.info(f"[PathPermissionManagerHook] Blocked {function_name}: {reason}")
|
|
1255
|
+
|
|
1256
|
+
return HookResult(allowed=allowed, metadata={"reason": reason} if reason else {})
|
|
1257
|
+
|
|
1258
|
+
except Exception as e:
|
|
1259
|
+
logger.error(f"[PathPermissionManagerHook] Error checking permissions for {function_name}: {e}")
|
|
1260
|
+
# Fail closed - deny access on permission check errors
|
|
1261
|
+
return HookResult(allowed=False, metadata={"error": str(e), "reason": "Permission check failed"})
|