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

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

Potentially problematic release.


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

Files changed (268) hide show
  1. massgen/__init__.py +142 -8
  2. massgen/adapters/__init__.py +29 -0
  3. massgen/adapters/ag2_adapter.py +483 -0
  4. massgen/adapters/base.py +183 -0
  5. massgen/adapters/tests/__init__.py +0 -0
  6. massgen/adapters/tests/test_ag2_adapter.py +439 -0
  7. massgen/adapters/tests/test_agent_adapter.py +128 -0
  8. massgen/adapters/utils/__init__.py +2 -0
  9. massgen/adapters/utils/ag2_utils.py +236 -0
  10. massgen/adapters/utils/tests/__init__.py +0 -0
  11. massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
  12. massgen/agent_config.py +329 -55
  13. massgen/api_params_handler/__init__.py +10 -0
  14. massgen/api_params_handler/_api_params_handler_base.py +99 -0
  15. massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
  16. massgen/api_params_handler/_claude_api_params_handler.py +113 -0
  17. massgen/api_params_handler/_response_api_params_handler.py +130 -0
  18. massgen/backend/__init__.py +39 -4
  19. massgen/backend/azure_openai.py +385 -0
  20. massgen/backend/base.py +341 -69
  21. massgen/backend/base_with_mcp.py +1102 -0
  22. massgen/backend/capabilities.py +386 -0
  23. massgen/backend/chat_completions.py +577 -130
  24. massgen/backend/claude.py +1033 -537
  25. massgen/backend/claude_code.py +1203 -0
  26. massgen/backend/cli_base.py +209 -0
  27. massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
  28. massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
  29. massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
  30. massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
  31. massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
  32. massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
  33. massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
  34. massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
  35. massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
  36. massgen/backend/docs/inference_backend.md +257 -0
  37. massgen/backend/docs/permissions_and_context_files.md +1085 -0
  38. massgen/backend/external.py +126 -0
  39. massgen/backend/gemini.py +1850 -241
  40. massgen/backend/grok.py +40 -156
  41. massgen/backend/inference.py +156 -0
  42. massgen/backend/lmstudio.py +171 -0
  43. massgen/backend/response.py +1095 -322
  44. massgen/chat_agent.py +131 -113
  45. massgen/cli.py +1560 -275
  46. massgen/config_builder.py +2396 -0
  47. massgen/configs/BACKEND_CONFIGURATION.md +458 -0
  48. massgen/configs/README.md +559 -216
  49. massgen/configs/ag2/ag2_case_study.yaml +27 -0
  50. massgen/configs/ag2/ag2_coder.yaml +34 -0
  51. massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
  52. massgen/configs/ag2/ag2_gemini.yaml +27 -0
  53. massgen/configs/ag2/ag2_groupchat.yaml +108 -0
  54. massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
  55. massgen/configs/ag2/ag2_single_agent.yaml +21 -0
  56. massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
  57. massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
  58. massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
  59. massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
  60. massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
  61. massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
  62. massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
  63. massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
  64. massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
  65. massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
  66. massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
  67. massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
  68. massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
  69. massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
  70. massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
  71. massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
  72. massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
  73. massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
  74. massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
  75. massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
  76. massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
  77. massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
  78. massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
  79. massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
  80. massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
  81. massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
  82. massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
  83. massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
  84. massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
  85. massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
  86. massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
  87. massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
  88. massgen/configs/debug/skip_coordination_test.yaml +27 -0
  89. massgen/configs/debug/test_sdk_migration.yaml +17 -0
  90. massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
  91. massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
  92. massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
  93. massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
  94. massgen/configs/providers/claude/claude.yaml +14 -0
  95. massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
  96. massgen/configs/providers/local/lmstudio.yaml +11 -0
  97. massgen/configs/providers/openai/gpt5.yaml +46 -0
  98. massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
  99. massgen/configs/providers/others/grok_single_agent.yaml +19 -0
  100. massgen/configs/providers/others/zai_coding_team.yaml +108 -0
  101. massgen/configs/providers/others/zai_glm45.yaml +12 -0
  102. massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
  103. massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
  104. massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
  105. massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
  106. massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
  107. massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
  108. massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
  109. massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
  110. massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
  111. massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
  112. massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
  113. massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
  114. massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
  115. massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
  116. massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
  117. massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
  118. massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
  119. massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
  120. massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
  121. massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
  122. massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
  123. massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
  124. massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
  125. massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
  126. massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
  127. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
  128. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
  129. massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
  130. massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
  131. massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
  132. massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
  133. massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
  134. massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
  135. massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
  136. massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
  137. massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
  138. massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
  139. massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
  140. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
  141. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
  142. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
  143. massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
  144. massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
  145. massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
  146. massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
  147. massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
  148. massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
  149. massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
  150. massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
  151. massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
  152. massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
  153. massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
  154. massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
  155. massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
  156. massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
  157. massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
  158. massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
  159. massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
  160. massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
  161. massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
  162. massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
  163. massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
  164. massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
  165. massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
  166. massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
  167. massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
  168. massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
  169. massgen/coordination_tracker.py +708 -0
  170. massgen/docker/README.md +462 -0
  171. massgen/filesystem_manager/__init__.py +21 -0
  172. massgen/filesystem_manager/_base.py +9 -0
  173. massgen/filesystem_manager/_code_execution_server.py +545 -0
  174. massgen/filesystem_manager/_docker_manager.py +477 -0
  175. massgen/filesystem_manager/_file_operation_tracker.py +248 -0
  176. massgen/filesystem_manager/_filesystem_manager.py +813 -0
  177. massgen/filesystem_manager/_path_permission_manager.py +1261 -0
  178. massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
  179. massgen/formatter/__init__.py +10 -0
  180. massgen/formatter/_chat_completions_formatter.py +284 -0
  181. massgen/formatter/_claude_formatter.py +235 -0
  182. massgen/formatter/_formatter_base.py +156 -0
  183. massgen/formatter/_response_formatter.py +263 -0
  184. massgen/frontend/__init__.py +1 -2
  185. massgen/frontend/coordination_ui.py +471 -286
  186. massgen/frontend/displays/base_display.py +56 -11
  187. massgen/frontend/displays/create_coordination_table.py +1956 -0
  188. massgen/frontend/displays/rich_terminal_display.py +1259 -619
  189. massgen/frontend/displays/simple_display.py +9 -4
  190. massgen/frontend/displays/terminal_display.py +27 -68
  191. massgen/logger_config.py +681 -0
  192. massgen/mcp_tools/README.md +232 -0
  193. massgen/mcp_tools/__init__.py +105 -0
  194. massgen/mcp_tools/backend_utils.py +1035 -0
  195. massgen/mcp_tools/circuit_breaker.py +195 -0
  196. massgen/mcp_tools/client.py +894 -0
  197. massgen/mcp_tools/config_validator.py +138 -0
  198. massgen/mcp_tools/docs/circuit_breaker.md +646 -0
  199. massgen/mcp_tools/docs/client.md +950 -0
  200. massgen/mcp_tools/docs/config_validator.md +478 -0
  201. massgen/mcp_tools/docs/exceptions.md +1165 -0
  202. massgen/mcp_tools/docs/security.md +854 -0
  203. massgen/mcp_tools/exceptions.py +338 -0
  204. massgen/mcp_tools/hooks.py +212 -0
  205. massgen/mcp_tools/security.py +780 -0
  206. massgen/message_templates.py +342 -64
  207. massgen/orchestrator.py +1515 -241
  208. massgen/stream_chunk/__init__.py +35 -0
  209. massgen/stream_chunk/base.py +92 -0
  210. massgen/stream_chunk/multimodal.py +237 -0
  211. massgen/stream_chunk/text.py +162 -0
  212. massgen/tests/mcp_test_server.py +150 -0
  213. massgen/tests/multi_turn_conversation_design.md +0 -8
  214. massgen/tests/test_azure_openai_backend.py +156 -0
  215. massgen/tests/test_backend_capabilities.py +262 -0
  216. massgen/tests/test_backend_event_loop_all.py +179 -0
  217. massgen/tests/test_chat_completions_refactor.py +142 -0
  218. massgen/tests/test_claude_backend.py +15 -28
  219. massgen/tests/test_claude_code.py +268 -0
  220. massgen/tests/test_claude_code_context_sharing.py +233 -0
  221. massgen/tests/test_claude_code_orchestrator.py +175 -0
  222. massgen/tests/test_cli_backends.py +180 -0
  223. massgen/tests/test_code_execution.py +679 -0
  224. massgen/tests/test_external_agent_backend.py +134 -0
  225. massgen/tests/test_final_presentation_fallback.py +237 -0
  226. massgen/tests/test_gemini_planning_mode.py +351 -0
  227. massgen/tests/test_grok_backend.py +7 -10
  228. massgen/tests/test_http_mcp_server.py +42 -0
  229. massgen/tests/test_integration_simple.py +198 -0
  230. massgen/tests/test_mcp_blocking.py +125 -0
  231. massgen/tests/test_message_context_building.py +29 -47
  232. massgen/tests/test_orchestrator_final_presentation.py +48 -0
  233. massgen/tests/test_path_permission_manager.py +2087 -0
  234. massgen/tests/test_rich_terminal_display.py +14 -13
  235. massgen/tests/test_timeout.py +133 -0
  236. massgen/tests/test_v3_3agents.py +11 -12
  237. massgen/tests/test_v3_simple.py +8 -13
  238. massgen/tests/test_v3_three_agents.py +11 -18
  239. massgen/tests/test_v3_two_agents.py +8 -13
  240. massgen/token_manager/__init__.py +7 -0
  241. massgen/token_manager/token_manager.py +400 -0
  242. massgen/utils.py +52 -16
  243. massgen/v1/agent.py +45 -91
  244. massgen/v1/agents.py +18 -53
  245. massgen/v1/backends/gemini.py +50 -153
  246. massgen/v1/backends/grok.py +21 -54
  247. massgen/v1/backends/oai.py +39 -111
  248. massgen/v1/cli.py +36 -93
  249. massgen/v1/config.py +8 -12
  250. massgen/v1/logging.py +43 -127
  251. massgen/v1/main.py +18 -32
  252. massgen/v1/orchestrator.py +68 -209
  253. massgen/v1/streaming_display.py +62 -163
  254. massgen/v1/tools.py +8 -12
  255. massgen/v1/types.py +9 -23
  256. massgen/v1/utils.py +5 -23
  257. massgen-0.1.0.dist-info/METADATA +1245 -0
  258. massgen-0.1.0.dist-info/RECORD +273 -0
  259. massgen-0.1.0.dist-info/entry_points.txt +2 -0
  260. massgen/frontend/logging/__init__.py +0 -9
  261. massgen/frontend/logging/realtime_logger.py +0 -197
  262. massgen-0.0.3.dist-info/METADATA +0 -568
  263. massgen-0.0.3.dist-info/RECORD +0 -76
  264. massgen-0.0.3.dist-info/entry_points.txt +0 -2
  265. /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
  266. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
  267. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
  268. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,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"})