mcpower-proxy 0.0.58__py3-none-any.whl → 0.0.73__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.
Files changed (36) hide show
  1. ide_tools/__init__.py +12 -0
  2. ide_tools/common/__init__.py +6 -0
  3. ide_tools/common/hooks/__init__.py +6 -0
  4. ide_tools/common/hooks/init.py +125 -0
  5. ide_tools/common/hooks/output.py +64 -0
  6. ide_tools/common/hooks/prompt_submit.py +186 -0
  7. ide_tools/common/hooks/read_file.py +170 -0
  8. ide_tools/common/hooks/shell_execution.py +196 -0
  9. ide_tools/common/hooks/types.py +35 -0
  10. ide_tools/common/hooks/utils.py +276 -0
  11. ide_tools/cursor/__init__.py +11 -0
  12. ide_tools/cursor/constants.py +58 -0
  13. ide_tools/cursor/format.py +35 -0
  14. ide_tools/cursor/router.py +100 -0
  15. ide_tools/router.py +48 -0
  16. main.py +11 -4
  17. {mcpower_proxy-0.0.58.dist-info → mcpower_proxy-0.0.73.dist-info}/METADATA +15 -3
  18. mcpower_proxy-0.0.73.dist-info/RECORD +59 -0
  19. {mcpower_proxy-0.0.58.dist-info → mcpower_proxy-0.0.73.dist-info}/top_level.txt +1 -0
  20. modules/apis/security_policy.py +11 -6
  21. modules/decision_handler.py +219 -0
  22. modules/logs/audit_trail.py +22 -17
  23. modules/logs/logger.py +14 -18
  24. modules/redaction/redactor.py +112 -107
  25. modules/ui/__init__.py +1 -1
  26. modules/ui/confirmation.py +0 -1
  27. modules/utils/cli.py +36 -6
  28. modules/utils/ids.py +55 -10
  29. modules/utils/json.py +3 -3
  30. wrapper/__version__.py +1 -1
  31. wrapper/middleware.py +121 -210
  32. wrapper/server.py +19 -11
  33. mcpower_proxy-0.0.58.dist-info/RECORD +0 -43
  34. {mcpower_proxy-0.0.58.dist-info → mcpower_proxy-0.0.73.dist-info}/WHEEL +0 -0
  35. {mcpower_proxy-0.0.58.dist-info → mcpower_proxy-0.0.73.dist-info}/entry_points.txt +0 -0
  36. {mcpower_proxy-0.0.58.dist-info → mcpower_proxy-0.0.73.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,196 @@
1
+ """
2
+ Common shell execution handler - IDE-agnostic
3
+
4
+ Handles both request (before) and response (after) inspection for shell commands.
5
+ """
6
+
7
+ import sys
8
+ from typing import Optional, Dict, List
9
+
10
+ from modules.logs.audit_trail import AuditTrailLogger
11
+ from modules.logs.logger import MCPLogger
12
+ from modules.redaction import redact
13
+ from modules.utils.ids import get_session_id, read_app_uid, get_project_mcpower_dir
14
+ from .types import HookConfig
15
+ from .output import output_result, output_error
16
+ from .utils import create_validator, inspect_and_enforce
17
+
18
+
19
+ async def handle_shell_execution(
20
+ logger: MCPLogger,
21
+ audit_logger: AuditTrailLogger,
22
+ stdin_input: str,
23
+ prompt_id: str,
24
+ event_id: str,
25
+ cwd: Optional[str],
26
+ config: HookConfig,
27
+ tool_name: str,
28
+ is_request: bool = True
29
+ ):
30
+ """
31
+ Generic shell execution handler - handles both request and response
32
+
33
+ Args:
34
+ logger: Logger instance
35
+ audit_logger: Audit logger instance
36
+ stdin_input: Raw input string from stdin
37
+ prompt_id: Prompt identifier
38
+ event_id: Event identifier
39
+ cwd: Current working directory
40
+ config: Hook configuration (IDE-specific)
41
+ tool_name: IDE-specific tool name (e.g., "beforeShellExecution", "PreToolUse(Bash)")
42
+ is_request: True for before (request), False for after (response)
43
+ """
44
+ await _handle_shell_operation(
45
+ logger=logger,
46
+ audit_logger=audit_logger,
47
+ stdin_input=stdin_input,
48
+ prompt_id=prompt_id,
49
+ event_id=event_id,
50
+ cwd=cwd,
51
+ config=config,
52
+ is_request=is_request,
53
+ required_fields={"command": str, "cwd": str} if is_request else {"command": str, "output": str},
54
+ redact_fields=["command"] if is_request else ["command", "output"],
55
+ tool_name=tool_name,
56
+ operation_name="Command" if is_request else "Command output",
57
+ audit_event_type="agent_request" if is_request else "mcp_response",
58
+ audit_forwarded_event_type="agent_request_forwarded" if is_request else "mcp_response_forwarded"
59
+ )
60
+
61
+
62
+ async def _handle_shell_operation(
63
+ logger: MCPLogger,
64
+ audit_logger: AuditTrailLogger,
65
+ stdin_input: str,
66
+ prompt_id: str,
67
+ event_id: str,
68
+ cwd: Optional[str],
69
+ config: HookConfig,
70
+ is_request: bool,
71
+ required_fields: Dict[str, type],
72
+ redact_fields: List[str],
73
+ tool_name: str,
74
+ operation_name: str,
75
+ audit_event_type: str,
76
+ audit_forwarded_event_type: str
77
+ ):
78
+ """
79
+ Internal shell operation handler - shared logic for request and response
80
+
81
+ Args:
82
+ is_request: True for request inspection, False for response inspection
83
+ required_fields: Fields to validate in input
84
+ redact_fields: Fields to redact for logging and API calls
85
+ tool_name: Hook name (e.g., "beforeShellExecution", "afterShellExecution")
86
+ operation_name: Display name (e.g., "Command", "Command output")
87
+ audit_event_type: Audit event name for incoming operation
88
+ audit_forwarded_event_type: Audit event name for forwarded operation
89
+ """
90
+ session_id = get_session_id()
91
+
92
+ logger.info(f"{tool_name} handler started (client={config.client_name}, prompt_id={prompt_id}, event_id={event_id}, cwd={cwd})")
93
+
94
+ try:
95
+ # Validate input
96
+ try:
97
+ validator = create_validator(required_fields=required_fields)
98
+ input_data = validator(stdin_input)
99
+ except ValueError as e:
100
+ logger.error(f"Input validation error: {e}")
101
+ output_error(logger, config.output_format, "permission", str(e))
102
+ return
103
+
104
+ app_uid = read_app_uid(logger, get_project_mcpower_dir(cwd))
105
+ audit_logger.set_app_uid(app_uid)
106
+
107
+ # Redact sensitive data for logging
108
+ redacted_data = {}
109
+ for k, v in input_data.items():
110
+ if k in required_fields:
111
+ redacted_data[k] = redact(v) if k in redact_fields else v
112
+
113
+ logger.info(f"Analyzing {tool_name}: {redacted_data}")
114
+
115
+ # Use different structure for request vs response events
116
+ # Requests: params nested, Responses: unpacked at root
117
+ if is_request:
118
+ audit_data = {
119
+ "server": config.server_name,
120
+ "tool": tool_name,
121
+ "params": redacted_data
122
+ }
123
+ else:
124
+ audit_data = {
125
+ "server": config.server_name,
126
+ "tool": tool_name,
127
+ **redacted_data
128
+ }
129
+
130
+ audit_logger.log_event(
131
+ audit_event_type,
132
+ audit_data,
133
+ event_id=event_id
134
+ )
135
+
136
+ # Build content_data with redacted fields
137
+ content_data = redacted_data
138
+
139
+ # Call security API and enforce decision
140
+ try:
141
+ decision = await inspect_and_enforce(
142
+ is_request=is_request,
143
+ session_id=session_id,
144
+ logger=logger,
145
+ audit_logger=audit_logger,
146
+ app_uid=app_uid,
147
+ event_id=event_id,
148
+ server_name=config.server_name,
149
+ tool_name=tool_name,
150
+ content_data=content_data,
151
+ prompt_id=prompt_id,
152
+ cwd=cwd,
153
+ client_name=config.client_name
154
+ )
155
+
156
+ # Log audit event for forwarding
157
+ # Use different structure for request vs response
158
+ if is_request:
159
+ forwarded_data = {
160
+ "server": config.server_name,
161
+ "tool": tool_name,
162
+ "params": redacted_data
163
+ }
164
+ else:
165
+ forwarded_data = {
166
+ "server": config.server_name,
167
+ "tool": tool_name,
168
+ **redacted_data
169
+ }
170
+
171
+ audit_logger.log_event(
172
+ audit_forwarded_event_type,
173
+ forwarded_data,
174
+ event_id=event_id
175
+ )
176
+
177
+ reasons = decision.get("reasons", [])
178
+ user_message = f"{operation_name} approved"
179
+ if not reasons:
180
+ agent_message = f"{operation_name} approved by security policy"
181
+ else:
182
+ agent_message = f"{operation_name} approved: {'; '.join(reasons)}"
183
+ output_result(logger, config.output_format, "permission", True, user_message, agent_message)
184
+
185
+ except Exception as e:
186
+ # Decision enforcement failed - block
187
+ error_msg = str(e)
188
+ user_message = f"{operation_name} blocked by security policy"
189
+ if "User blocked" in error_msg or "User denied" in error_msg:
190
+ user_message = f"{operation_name} blocked by user"
191
+
192
+ output_result(logger, config.output_format, "permission", False, user_message, error_msg)
193
+
194
+ except Exception as e:
195
+ logger.error(f"Unexpected error in {tool_name} handler: {e}", exc_info=True)
196
+ output_error(logger, config.output_format, "permission", f"Unexpected error: {str(e)}")
@@ -0,0 +1,35 @@
1
+ """
2
+ Common types for IDE hooks - IDE-agnostic
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable, Optional
7
+
8
+
9
+ @dataclass
10
+ class OutputFormat:
11
+ """
12
+ Defines how to format hook output for a specific IDE.
13
+ This is a generic interface - IDEs provide their own implementations.
14
+ """
15
+ # Exit codes
16
+ allow_exit_code: int
17
+ deny_exit_code: int
18
+ error_exit_code: int
19
+
20
+ # Output formatter function
21
+ # Args: (hook_type: str, allowed: bool, user_msg: Optional[str], agent_msg: Optional[str]) -> str
22
+ formatter: Callable[[str, bool, Optional[str], Optional[str]], str]
23
+
24
+
25
+ @dataclass
26
+ class HookConfig:
27
+ """
28
+ Configuration for a specific hook execution.
29
+ IDE-specific modules create instances of this with their own output format.
30
+ """
31
+ output_format: OutputFormat
32
+ server_name: str # IDE-specific tool server name
33
+ client_name: str # IDE-specific client name (e.g. "cursor", "claude-code")
34
+ max_content_length: int # Maximum content length before skipping API call
35
+
@@ -0,0 +1,276 @@
1
+ """
2
+ Common utilities for IDE hooks - IDE-agnostic
3
+ """
4
+
5
+ import json
6
+ import re
7
+ from collections import Counter
8
+ from typing import Dict, Any, List, Callable, Optional
9
+
10
+ from mcpower_shared.mcp_types import create_policy_request, create_policy_response, AgentContext, EnvironmentContext
11
+ from modules.apis.security_policy import SecurityPolicyClient
12
+ from modules.decision_handler import DecisionHandler
13
+ from modules.logs.audit_trail import AuditTrailLogger
14
+ from modules.logs.logger import MCPLogger
15
+ from modules.redaction import redact
16
+ from modules.utils.json import safe_json_dumps
17
+ from wrapper.__version__ import __version__
18
+
19
+
20
+ def create_validator(
21
+ required_fields: Dict[str, type],
22
+ optional_fields: Optional[Dict[str, type]] = None
23
+ ) -> Callable[[str], Dict[str, Any]]:
24
+ """
25
+ Factory for input validators
26
+
27
+ Args:
28
+ required_fields: Dict mapping field names to their expected types
29
+ optional_fields: Dict mapping optional field names to their expected types
30
+
31
+ Returns:
32
+ Validator function that parses and validates input
33
+ """
34
+
35
+ def parse_and_validate_input(stdin_input: str) -> Dict[str, Any]:
36
+ try:
37
+ if not stdin_input.strip():
38
+ raise ValueError("No input provided")
39
+ input_data = json.loads(stdin_input)
40
+ except json.JSONDecodeError as e:
41
+ raise ValueError(f"Failed to parse input: {e}")
42
+
43
+ for field, expected_type in required_fields.items():
44
+ if field not in input_data:
45
+ raise ValueError(f"No {field} provided in input")
46
+ if not isinstance(input_data[field], expected_type):
47
+ raise ValueError(f"{field} must be a {expected_type.__name__}")
48
+
49
+ if optional_fields:
50
+ for field, expected_type in optional_fields.items():
51
+ if field in input_data and not isinstance(input_data[field], expected_type):
52
+ raise ValueError(f"{field} must be a {expected_type.__name__}")
53
+
54
+ return input_data
55
+
56
+ return parse_and_validate_input
57
+
58
+
59
+ def extract_redaction_patterns(redacted_content: str) -> Dict[str, int]:
60
+ """
61
+ Extract redaction pattern types and their counts from redacted content
62
+
63
+ Args:
64
+ redacted_content: Content with [REDACTED-type] placeholders
65
+
66
+ Returns:
67
+ Dict mapping redaction types to counts
68
+ """
69
+ pattern = r'\[REDACTED-([^\]]+)\]'
70
+ matches = re.findall(pattern, redacted_content)
71
+ return dict(Counter(matches))
72
+
73
+
74
+ def build_sensitive_data_types(patterns: Dict[str, int], context: str = "file") -> Dict[str, Dict[str, Any]]:
75
+ """
76
+ Convert redaction patterns to structured sensitive_data_types dict
77
+
78
+ Args:
79
+ patterns: Dict mapping pattern text to occurrence counts (from extract_redaction_patterns)
80
+ context: Context string for description (e.g., "file", "prompt text")
81
+
82
+ Returns:
83
+ Dict mapping data types to occurrence info with descriptions
84
+ """
85
+ sensitive_data_types = {}
86
+ for pattern_text, count in patterns.items():
87
+ data_type = pattern_text.replace("[REDACTED-", "").replace("]", "")
88
+ sensitive_data_types[data_type] = {
89
+ "occurrences": count,
90
+ "description": f"Found {count} instance(s) of {data_type} in {context}"
91
+ }
92
+ return sensitive_data_types
93
+
94
+
95
+ def process_single_file_for_redaction(
96
+ file_path: str,
97
+ content: str,
98
+ logger: MCPLogger
99
+ ) -> Optional[Dict[str, Any]]:
100
+ """
101
+ Process a single file's content for redaction patterns
102
+
103
+ Args:
104
+ file_path: Path to the file being processed
105
+ content: File content to check for redactions
106
+ logger: MCPLogger instance
107
+
108
+ Returns:
109
+ Dict with redaction info if sensitive data found, None otherwise
110
+ """
111
+ redacted = redact(content)
112
+ patterns = extract_redaction_patterns(redacted)
113
+ if patterns:
114
+ sensitive_data_types = build_sensitive_data_types(patterns, "file")
115
+ logger.info(f"Found {len(patterns)} sensitive data type(s) in: {file_path}")
116
+ return {
117
+ "file_path": file_path,
118
+ "contains_sensitive_data": True,
119
+ "sensitive_data_types": sensitive_data_types,
120
+ "risk_summary": f"File contains {sum(patterns.values())} sensitive data item(s) across {len(patterns)} type(s)"
121
+ }
122
+ return None
123
+
124
+
125
+ def process_attachments_for_redaction(
126
+ attachments: List[Dict[str, Any]],
127
+ logger: MCPLogger
128
+ ) -> List[Dict[str, Any]]:
129
+ """
130
+ Process file attachments and extract redaction patterns
131
+
132
+ Args:
133
+ attachments: List of attachment dicts with 'type' and 'file_path' or 'filePath'
134
+ logger: MCPLogger instance
135
+
136
+ Returns:
137
+ List of files with redactions found
138
+ """
139
+ files_with_redactions = []
140
+
141
+ for attachment in attachments:
142
+ att_type = attachment.get("type")
143
+ att_path = attachment.get("file_path") or attachment.get("filePath")
144
+
145
+ if att_type != "file":
146
+ logger.debug(f"Skipping non-file attachment (type={att_type}): {att_path}")
147
+ continue
148
+
149
+ if not att_path:
150
+ logger.debug("Skipping attachment with no file_path")
151
+ continue
152
+
153
+ try:
154
+ with open(att_path, 'r', encoding='utf-8', errors='replace') as f:
155
+ content = f.read()
156
+
157
+ result = process_single_file_for_redaction(att_path, content, logger)
158
+ if result:
159
+ files_with_redactions.append(result)
160
+
161
+ except Exception as e:
162
+ logger.warning(f"Could not read attachment file {att_path}: {e}")
163
+
164
+ return files_with_redactions
165
+
166
+
167
+ async def inspect_and_enforce(
168
+ is_request: bool,
169
+ session_id: str,
170
+ logger: MCPLogger,
171
+ audit_logger: AuditTrailLogger,
172
+ app_uid: str,
173
+ event_id: str,
174
+ server_name: str,
175
+ tool_name: str,
176
+ content_data: Dict[str, Any],
177
+ prompt_id: str,
178
+ cwd: Optional[str],
179
+ current_files: Optional[List[str]] = None,
180
+ client_name: str = "ide-tools"
181
+ ) -> Dict[str, Any]:
182
+ """
183
+ Generic handler for API inspection and decision enforcement
184
+
185
+ Args:
186
+ is_request: True for request inspection, False for response inspection
187
+ session_id: Session identifier
188
+ logger: Logger instance
189
+ audit_logger: Audit logger instance
190
+ app_uid: Application UID
191
+ event_id: Event identifier
192
+ server_name: Server name (IDE-specific, e.g. "cursor_tools_mcp")
193
+ tool_name: Tool/hook name
194
+ content_data: Data to inspect
195
+ prompt_id: Prompt identifier
196
+ cwd: Current working directory
197
+ current_files: Optional list of current files
198
+ client_name: Client name (e.g. "cursor", "claude-code")
199
+
200
+ Returns:
201
+ Decision dict from security API
202
+
203
+ Raises:
204
+ Exception: If decision blocks the operation or API call fails
205
+ """
206
+ agent_context = AgentContext(
207
+ last_user_prompt="",
208
+ user_prompt_id=prompt_id,
209
+ context_summary=""
210
+ )
211
+
212
+ env_context = EnvironmentContext(
213
+ session_id=session_id,
214
+ workspace={
215
+ "roots": [cwd] if cwd else [],
216
+ "current_files": current_files or []
217
+ },
218
+ client=client_name,
219
+ client_version=__version__,
220
+ selection_hash=""
221
+ )
222
+
223
+ async with SecurityPolicyClient(
224
+ session_id=session_id,
225
+ logger=logger,
226
+ audit_logger=audit_logger,
227
+ app_id=app_uid
228
+ ) as client:
229
+ if is_request:
230
+ policy_request = create_policy_request(
231
+ event_id=event_id,
232
+ server_name=server_name,
233
+ server_transport="stdio",
234
+ tool_name=tool_name,
235
+ agent_context=agent_context,
236
+ env_context=env_context,
237
+ arguments=content_data
238
+ )
239
+ decision = await client.inspect_policy_request(
240
+ policy_request=policy_request,
241
+ prompt_id=prompt_id
242
+ )
243
+ else:
244
+ policy_response = create_policy_response(
245
+ event_id=event_id,
246
+ server_name=server_name,
247
+ server_transport="stdio",
248
+ tool_name=tool_name,
249
+ response_content=safe_json_dumps(content_data),
250
+ agent_context=agent_context,
251
+ env_context=env_context
252
+ )
253
+ decision = await client.inspect_policy_response(
254
+ policy_response=policy_response,
255
+ prompt_id=prompt_id
256
+ )
257
+
258
+ await DecisionHandler(
259
+ logger=logger,
260
+ audit_logger=audit_logger,
261
+ session_id=session_id,
262
+ app_id=app_uid
263
+ ).enforce_decision(
264
+ decision=decision,
265
+ is_request=is_request,
266
+ event_id=event_id,
267
+ tool_name=tool_name,
268
+ content_data=content_data,
269
+ operation_type="hook",
270
+ prompt_id=prompt_id,
271
+ server_name=server_name,
272
+ error_message_prefix=f"Operation blocked by security policy"
273
+ )
274
+
275
+ return decision
276
+
@@ -0,0 +1,11 @@
1
+ """
2
+ Cursor IDE Handler
3
+
4
+ Handles Cursor-specific hooks and operations.
5
+ """
6
+
7
+ from .router import route_cursor_hook
8
+
9
+ __all__ = [
10
+ "route_cursor_hook",
11
+ ]
@@ -0,0 +1,58 @@
1
+ """
2
+ Cursor Hook Constants
3
+
4
+ Configuration values specific to Cursor hook handlers.
5
+ """
6
+
7
+ from enum import Enum
8
+
9
+ from ide_tools.common.hooks.types import HookConfig, OutputFormat
10
+ from ide_tools.cursor.format import cursor_output_formatter
11
+
12
+
13
+ class HookPermission(str, Enum):
14
+ """Cursor hook response permission values"""
15
+ ALLOW = "allow"
16
+ DENY = "deny"
17
+
18
+
19
+ # Cursor-specific configuration
20
+ CURSOR_CONFIG = HookConfig(
21
+ output_format=OutputFormat(
22
+ allow_exit_code=0,
23
+ deny_exit_code=1,
24
+ error_exit_code=1,
25
+ formatter=cursor_output_formatter
26
+ ),
27
+ server_name="mcpower_cursor",
28
+ client_name="cursor",
29
+ max_content_length=100000
30
+ )
31
+
32
+ # Hook descriptions from https://cursor.com/docs/agent/hooks#hook-events
33
+ CURSOR_HOOKS = {
34
+ "beforeShellExecution": {
35
+ "name": "beforeShellExecution",
36
+ "description": "Triggered before a shell command is executed by the agent. "
37
+ "Allows inspection and potential blocking of shell commands.",
38
+ "version": "1.0.0"
39
+ },
40
+ "afterShellExecution": {
41
+ "name": "afterShellExecution",
42
+ "description": "Triggered after a shell command completes execution. "
43
+ "Provides access to command output and exit status.",
44
+ "version": "1.0.0"
45
+ },
46
+ "beforeReadFile": {
47
+ "name": "beforeReadFile",
48
+ "description": "Triggered before the agent reads a file. "
49
+ "Allows inspection and potential blocking of file read operations.",
50
+ "version": "1.0.0"
51
+ },
52
+ "beforeSubmitPrompt": {
53
+ "name": "beforeSubmitPrompt",
54
+ "description": "Triggered before a prompt is submitted to the AI model. "
55
+ "Allows inspection and modification of prompts.",
56
+ "version": "1.0.0"
57
+ }
58
+ }
@@ -0,0 +1,35 @@
1
+ """
2
+ Cursor-specific output formatting
3
+ """
4
+
5
+ import json
6
+ from typing import Optional
7
+
8
+
9
+ def cursor_output_formatter(hook_type: str, allowed: bool, user_msg: Optional[str], agent_msg: Optional[str]) -> str:
10
+ """
11
+ Format output for Cursor IDE
12
+
13
+ Args:
14
+ hook_type: "permission" or "continue"
15
+ allowed: True for allow/continue, False for deny/block
16
+ user_msg: Message for user
17
+ agent_msg: Message for agent/logs
18
+
19
+ Returns:
20
+ JSON string in Cursor format
21
+ """
22
+ if hook_type == "permission":
23
+ result = {"permission": "allow" if allowed else "deny"}
24
+ if user_msg:
25
+ result["user_message"] = user_msg
26
+ if agent_msg:
27
+ result["agent_message"] = agent_msg
28
+ else: # continue
29
+ result = {"continue": allowed}
30
+ if user_msg:
31
+ result["user_message"] = user_msg
32
+ if agent_msg:
33
+ result["agent_message"] = agent_msg
34
+
35
+ return json.dumps(result)