mcpower-proxy 0.0.74__py3-none-any.whl → 0.0.79__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 mcpower-proxy might be problematic. Click here for more details.

@@ -36,6 +36,7 @@ async def handle_init(
36
36
  logger: MCPLogger,
37
37
  audit_logger: AuditTrailLogger,
38
38
  event_id: str,
39
+ prompt_id: str,
39
40
  cwd: Optional[str],
40
41
  server_name: str,
41
42
  client_name: str,
@@ -48,6 +49,7 @@ async def handle_init(
48
49
  logger: Logger instance
49
50
  audit_logger: Audit logger instance
50
51
  event_id: Event identifier
52
+ prompt_id: Prompt identifier
51
53
  cwd: Current working directory
52
54
  server_name: IDE-specific server name (e.g. "cursor_tools_mcp")
53
55
  client_name: IDE-specific client name (e.g. "cursor", "claude-code")
@@ -57,23 +59,27 @@ async def handle_init(
57
59
  """
58
60
  session_id = get_session_id()
59
61
 
60
- logger.info(f"Init handler started (client={client_name}, event_id={event_id}, cwd={cwd})")
62
+ logger.info(f"Init handler started (client={client_name}, event_id={event_id}, prompt_id={prompt_id}, cwd={cwd})")
61
63
 
62
64
  try:
63
65
  app_uid = read_app_uid(logger, get_project_mcpower_dir(cwd))
64
66
  audit_logger.set_app_uid(app_uid)
65
67
 
66
- audit_logger.log_event("mcpower_start", {
67
- "wrapper_version": __version__,
68
- "wrapped_server_name": server_name,
69
- "client": client_name
70
- })
68
+ audit_logger.log_event(
69
+ "mcpower_start",
70
+ {
71
+ "wrapper_version": __version__,
72
+ "wrapped_server_name": server_name,
73
+ "client": client_name
74
+ }
75
+ )
71
76
 
72
77
  try:
73
78
  tools = [
74
79
  ToolRef(
75
80
  name=hook_info["name"],
76
- description=hook_info["description"],
81
+ description=f"Description:\n{hook_info['description']}\n\n"
82
+ f"inputSchema:\n{hook_info['parameters']}",
77
83
  version=hook_info["version"]
78
84
  )
79
85
  for hook_info in hooks.values()
@@ -8,6 +8,7 @@ from modules.logs.audit_trail import AuditTrailLogger
8
8
  from modules.logs.logger import MCPLogger
9
9
  from modules.redaction import redact
10
10
  from modules.utils.ids import get_session_id, read_app_uid, get_project_mcpower_dir
11
+ from modules.utils.string import truncate_at
11
12
  from .output import output_result, output_error
12
13
  from .types import HookConfig
13
14
  from .utils import create_validator, extract_redaction_patterns, process_attachments_for_redaction, inspect_and_enforce
@@ -65,9 +66,10 @@ async def handle_prompt_submit(
65
66
  {
66
67
  "server": config.server_name,
67
68
  "tool": tool_name,
68
- "params": {"prompt": f"{redacted_prompt[:20]}...", "attachments_count": len(attachments)}
69
+ "params": {"prompt": truncate_at(redacted_prompt, 100), "attachments_count": len(attachments)}
69
70
  },
70
- event_id=event_id
71
+ event_id=event_id,
72
+ prompt_id=prompt_id
71
73
  )
72
74
 
73
75
  prompt_patterns = extract_redaction_patterns(redacted_prompt)
@@ -110,7 +112,8 @@ async def handle_prompt_submit(
110
112
  "tool": tool_name,
111
113
  "params": {"redactions_found": has_any_redactions}
112
114
  },
113
- event_id=event_id
115
+ event_id=event_id,
116
+ prompt_id=prompt_id
114
117
  )
115
118
 
116
119
  reasons = decision.get("reasons", [])
@@ -65,7 +65,8 @@ async def handle_read_file(
65
65
  "tool": tool_name,
66
66
  "params": {"file_path": file_path, "attachments_count": len(attachments)}
67
67
  },
68
- event_id=event_id
68
+ event_id=event_id,
69
+ prompt_id=prompt_id
69
70
  )
70
71
 
71
72
  # Check content length - skip API if too large
@@ -84,7 +85,8 @@ async def handle_read_file(
84
85
  "content_too_large": True
85
86
  }
86
87
  },
87
- event_id=event_id
88
+ event_id=event_id,
89
+ prompt_id=prompt_id
88
90
  )
89
91
 
90
92
  output_result(logger, config.output_format, "permission", True)
@@ -145,7 +147,8 @@ async def handle_read_file(
145
147
  "content_length": len(provided_content),
146
148
  "attachments_with_redactions": len(files_with_redactions)}
147
149
  },
148
- event_id=event_id
150
+ event_id=event_id,
151
+ prompt_id=prompt_id
149
152
  )
150
153
 
151
154
  reasons = decision.get("reasons", [])
@@ -38,7 +38,7 @@ def extract_and_redact_command_files(
38
38
 
39
39
  try:
40
40
  # Parse command to extract input files
41
- _, input_files = parse_shell_command(command)
41
+ _, input_files = parse_shell_command(command, initial_cwd=cwd)
42
42
 
43
43
  logger.info(f"Extracted {len(input_files)} input files from command: {input_files}")
44
44
 
@@ -203,7 +203,8 @@ async def _handle_shell_operation(
203
203
  audit_logger.log_event(
204
204
  audit_event_type,
205
205
  get_audit_data(),
206
- event_id=event_id
206
+ event_id=event_id,
207
+ prompt_id=prompt_id
207
208
  )
208
209
 
209
210
  # Build content_data with redacted fields and files
@@ -230,7 +231,8 @@ async def _handle_shell_operation(
230
231
  audit_logger.log_event(
231
232
  audit_forwarded_event_type,
232
233
  get_audit_data(),
233
- event_id=event_id
234
+ event_id=event_id,
235
+ prompt_id=prompt_id
234
236
  )
235
237
 
236
238
  reasons = decision.get("reasons", [])
@@ -5,15 +5,17 @@ Parses shell commands to extract sub-commands and file references using proper b
5
5
  """
6
6
 
7
7
  import bashlex
8
- from typing import List, Tuple, Set, Optional
8
+ import os
9
+ from typing import List, Tuple, Set, Optional, Dict
9
10
 
10
11
 
11
- def parse_shell_command(command: str) -> Tuple[List[str], List[str]]:
12
+ def parse_shell_command(command: str, initial_cwd: Optional[str] = None) -> Tuple[List[str], List[str]]:
12
13
  """
13
14
  Parse a shell command using bashlex and extract sub-commands and input files.
14
15
 
15
16
  Args:
16
17
  command: A shell command string (supports pipes, redirections, etc.)
18
+ initial_cwd: Initial working directory (defaults to current directory)
17
19
 
18
20
  Returns:
19
21
  A tuple of (sub_commands, input_files) where:
@@ -43,8 +45,14 @@ def parse_shell_command(command: str) -> Tuple[List[str], List[str]]:
43
45
  all_files: Set[str] = set()
44
46
  output_files: Set[str] = set()
45
47
 
48
+ # Track directory changes
49
+ context = {
50
+ 'cwd': initial_cwd or os.getcwd(),
51
+ 'file_to_cwd': {} # Map each file to the directory it was found in
52
+ }
53
+
46
54
  for ast in parts:
47
- _extract_from_ast(ast, command, sub_commands, all_files, output_files)
55
+ _extract_from_ast(ast, command, sub_commands, all_files, output_files, False, context)
48
56
 
49
57
  # Remove output-only files from the result
50
58
  input_files = sorted(list(all_files - output_files))
@@ -58,7 +66,8 @@ def _extract_from_ast(
58
66
  sub_commands: List[str],
59
67
  all_files: Set[str],
60
68
  output_files: Set[str],
61
- parent_is_pipe: bool = False
69
+ parent_is_pipe: bool = False,
70
+ context: Optional[Dict] = None
62
71
  ) -> None:
63
72
  """
64
73
  Recursively extract sub-commands and files from a bashlex AST node.
@@ -70,19 +79,24 @@ def _extract_from_ast(
70
79
  all_files: Set to add all file references to
71
80
  output_files: Set to add output-only files to
72
81
  parent_is_pipe: True if parent node is a pipe operator
82
+ context: Dictionary with 'cwd' for current working directory
73
83
  """
84
+ if context is None:
85
+ context = {'cwd': os.getcwd()}
86
+
74
87
  # Check node kind to determine type
75
88
  node_kind = getattr(node, 'kind', None)
76
89
 
77
90
  if node_kind == 'list':
78
- # List node contains multiple parts connected by operators
91
+ # List node contains multiple parts connected by operators (&&, ||, ;)
92
+ # Process sequentially to track directory changes
79
93
  if hasattr(node, 'parts'):
80
94
  for part in node.parts:
81
- _extract_from_ast(part, command, sub_commands, all_files, output_files, False)
95
+ _extract_from_ast(part, command, sub_commands, all_files, output_files, False, context)
82
96
 
83
97
  elif node_kind == 'pipeline':
84
98
  # Pipeline node - extract individual commands
85
- _extract_pipeline(node, command, sub_commands, all_files, output_files)
99
+ _extract_pipeline(node, command, sub_commands, all_files, output_files, context)
86
100
 
87
101
  elif node_kind == 'command':
88
102
  # Command node - extract the command text and analyze its parts
@@ -91,20 +105,38 @@ def _extract_from_ast(
91
105
  cmd_text = command[start:end]
92
106
  sub_commands.append(cmd_text)
93
107
 
108
+ # Get the command name (first word) for context
109
+ cmd_name = None
110
+ if hasattr(node, 'parts') and len(node.parts) > 0:
111
+ first_part = node.parts[0]
112
+ if hasattr(first_part, 'word'):
113
+ cmd_name = first_part.word
114
+
115
+ # Check if this is a cd command and update context
116
+ if cmd_name == 'cd' and hasattr(node, 'parts') and len(node.parts) > 1:
117
+ second_part = node.parts[1]
118
+ if hasattr(second_part, 'word'):
119
+ target_dir = second_part.word
120
+ # Resolve the new directory
121
+ if os.path.isabs(target_dir):
122
+ context['cwd'] = target_dir
123
+ else:
124
+ context['cwd'] = os.path.normpath(os.path.join(context['cwd'], target_dir))
125
+
94
126
  # Extract files from command parts (arguments and redirections)
95
127
  if hasattr(node, 'parts'):
96
- for part in node.parts:
128
+ for i, part in enumerate(node.parts):
97
129
  part_kind = getattr(part, 'kind', None)
98
130
  if part_kind == 'redirect':
99
- _extract_redirect(part, command, all_files, output_files)
100
- else:
101
- _extract_files_from_node(part, command, all_files, output_files)
131
+ _extract_redirect(part, command, all_files, output_files, context)
132
+ elif i > 0: # Skip the command name itself (index 0)
133
+ _extract_files_from_node(part, command, all_files, output_files, cmd_name, context)
102
134
 
103
135
  elif node_kind == 'compound':
104
136
  # Compound command (like if, while, for, etc.)
105
137
  if hasattr(node, 'list'):
106
138
  for item in node.list:
107
- _extract_from_ast(item, command, sub_commands, all_files, output_files, False)
139
+ _extract_from_ast(item, command, sub_commands, all_files, output_files, False, context)
108
140
 
109
141
  elif node_kind == 'operator':
110
142
  # Operator node (like &&, ||, ;) - ignore
@@ -115,45 +147,62 @@ def _extract_from_ast(
115
147
  pass
116
148
 
117
149
 
118
- def _extract_pipeline(node, command: str, sub_commands: List[str], all_files: Set[str], output_files: Set[str]) -> None:
150
+ def _extract_pipeline(node, command: str, sub_commands: List[str], all_files: Set[str], output_files: Set[str], context: Dict) -> None:
119
151
  """Extract commands from a pipeline node."""
120
152
  if hasattr(node, 'parts'):
121
153
  for part in node.parts:
122
154
  part_kind = getattr(part, 'kind', None)
123
155
  # Skip pipe nodes, only process commands
124
156
  if part_kind != 'pipe':
125
- _extract_from_ast(part, command, sub_commands, all_files, output_files, True)
157
+ _extract_from_ast(part, command, sub_commands, all_files, output_files, True, context)
126
158
 
127
159
 
128
- def _extract_files_from_node(node, command: str, all_files: Set[str], output_files: Set[str]) -> None:
129
- """Extract file references from a node."""
160
+ def _extract_files_from_node(node, command: str, all_files: Set[str], output_files: Set[str], cmd_name: Optional[str] = None, context: Optional[Dict] = None) -> None:
161
+ """Extract file references from a node.
162
+
163
+ Args:
164
+ node: bashlex AST node
165
+ command: Original command string
166
+ all_files: Set to add all file references to
167
+ output_files: Set to add output-only files to
168
+ cmd_name: Name of the command this node belongs to (for context)
169
+ context: Dictionary with 'cwd' for current working directory
170
+ """
171
+ if context is None:
172
+ context = {'cwd': os.getcwd()}
173
+
130
174
  node_kind = getattr(node, 'kind', None)
131
175
 
132
176
  if node_kind == 'word':
133
177
  # Word node - check if it's a file reference
134
178
  word = node.word if hasattr(node, 'word') else None
135
179
 
136
- if word and _looks_like_file(word):
137
- all_files.add(word)
180
+ if word and _looks_like_file(word, cmd_name):
181
+ # Resolve relative paths against current working directory
182
+ resolved_path = _resolve_path(word, context['cwd'])
183
+ all_files.add(resolved_path)
138
184
 
139
185
  # Recursively check parts (for command substitutions, etc.)
140
186
  if hasattr(node, 'parts'):
141
187
  for part in node.parts:
142
- _extract_files_from_node(part, command, all_files, output_files)
188
+ _extract_files_from_node(part, command, all_files, output_files, cmd_name, context)
143
189
 
144
190
  elif node_kind == 'commandsubstitution':
145
191
  # Command substitution $(...) - recursively parse
146
192
  if hasattr(node, 'command'):
147
- _extract_from_ast(node.command, command, [], all_files, output_files, False)
193
+ _extract_from_ast(node.command, command, [], all_files, output_files, False, context)
148
194
 
149
195
  elif node_kind == 'processsubstitution':
150
196
  # Process substitution <(...) or >(...) - recursively parse
151
197
  if hasattr(node, 'command'):
152
- _extract_from_ast(node.command, command, [], all_files, output_files, False)
198
+ _extract_from_ast(node.command, command, [], all_files, output_files, False, context)
153
199
 
154
200
 
155
- def _extract_redirect(redirect, command: str, all_files: Set[str], output_files: Set[str]) -> None:
201
+ def _extract_redirect(redirect, command: str, all_files: Set[str], output_files: Set[str], context: Optional[Dict] = None) -> None:
156
202
  """Extract file references from redirection nodes."""
203
+ if context is None:
204
+ context = {'cwd': os.getcwd()}
205
+
157
206
  redirect_type = getattr(redirect, 'type', None)
158
207
 
159
208
  # Get the target of the redirection
@@ -161,26 +210,49 @@ def _extract_redirect(redirect, command: str, all_files: Set[str], output_files:
161
210
  target = redirect.output
162
211
  target_word = target.word if hasattr(target, 'word') else None
163
212
 
164
- if target_word and _looks_like_file(target_word):
213
+ # Redirections always point to files, not directories
214
+ if target_word and _looks_like_file(target_word, None):
215
+ # Resolve relative paths against current working directory
216
+ resolved_path = _resolve_path(target_word, context['cwd'])
217
+
165
218
  # Determine if it's input or output
166
219
  if redirect_type in ('>', '>>', '>&', '>|', '&>'):
167
220
  # Output redirection
168
- output_files.add(target_word)
169
- all_files.add(target_word)
221
+ output_files.add(resolved_path)
222
+ all_files.add(resolved_path)
170
223
  elif redirect_type == '<':
171
224
  # Input redirection
172
- all_files.add(target_word)
225
+ all_files.add(resolved_path)
173
226
  else:
174
227
  # Unknown, be conservative and include it
175
- all_files.add(target_word)
228
+ all_files.add(resolved_path)
229
+
230
+
231
+ def _resolve_path(path: str, cwd: str) -> str:
232
+ """
233
+ Resolve a file path relative to a working directory.
234
+
235
+ Args:
236
+ path: File path (relative or absolute)
237
+ cwd: Current working directory
238
+
239
+ Returns:
240
+ Absolute path
241
+ """
242
+ if os.path.isabs(path):
243
+ return path
244
+ else:
245
+ return os.path.normpath(os.path.join(cwd, path))
176
246
 
177
247
 
178
- def _looks_like_file(word: str) -> bool:
248
+ def _looks_like_file(word: str, cmd_name: Optional[str] = None) -> bool:
179
249
  """
180
- Heuristic to determine if a word looks like a file path.
250
+ Heuristic to determine if a word is an actual readable file path.
251
+ Not patterns, not variables, not directories - actual files we can open.
181
252
 
182
253
  Args:
183
254
  word: A word from the command
255
+ cmd_name: The command this word belongs to (for context)
184
256
 
185
257
  Returns:
186
258
  True if it looks like a file path
@@ -188,65 +260,109 @@ def _looks_like_file(word: str) -> bool:
188
260
  if not word:
189
261
  return False
190
262
 
191
- # Filter out obvious non-files
263
+ # Commands that take directory arguments, not files
264
+ DIRECTORY_COMMANDS = {
265
+ 'cd', 'pushd', 'popd', 'mkdir', 'rmdir', 'chdir',
266
+ }
267
+
268
+ # If this is a directory command, reject all arguments
269
+ if cmd_name and cmd_name in DIRECTORY_COMMANDS:
270
+ return False
271
+
272
+ # Exclude URLs (http://, https://, ftp://, file://, etc.)
273
+ if '://' in word:
274
+ return False
192
275
 
193
- # Exclude shell glob patterns (wildcards without actual path)
194
- if word.startswith('*') and '/' not in word:
276
+ # Exclude shell meta-characters and patterns
277
+ if any(char in word for char in ['*', '?', '[', ']']): # Glob patterns
195
278
  return False
196
279
 
197
- # Exclude shell expansions and special characters
198
- if word.startswith('$') or '${' in word or '$(' in word:
280
+ if '$' in word or '`' in word: # Variables or command substitution
199
281
  return False
200
282
 
201
- # Exclude sed/awk patterns (contain / as delimiter but are patterns)
283
+ # Exclude sed/awk patterns
202
284
  if word.startswith('s/') and word.count('/') >= 2:
203
285
  return False
204
286
 
205
- # Exclude regex patterns (contain escaped characters or special regex chars)
206
- if '\\' in word or word.startswith('^') or word.endswith('$'):
287
+ # Exclude regex patterns
288
+ if word.startswith('^') or word.endswith('$'):
207
289
  return False
208
290
 
209
- # Exclude tokens that look like options
210
- if word.startswith('-') or word.startswith('+') or word.startswith('!'):
291
+ # Exclude options
292
+ if word.startswith('-') or word.startswith('+'):
211
293
  return False
212
294
 
213
- # Exclude relative path references
295
+ # Exclude bare dots
214
296
  if word in {'.', '..'}:
215
297
  return False
216
298
 
217
- # Exclude common directories that are just paths (not files)
218
- # Like /tmp, /dev, /usr, /etc without a filename
219
- if word in {'/tmp', '/dev', '/usr', '/etc', '/var', '/opt', '/home'}:
299
+ # Exclude bare directories (but /tmp/file is OK)
300
+ if word in {'/', '/tmp', '/dev', '/usr', '/etc', '/var', '/opt', '/home'}:
220
301
  return False
221
302
 
222
- # Check for common file patterns
223
- # Has an extension (but not just an extension)
224
- if '.' in word and not word.startswith('.') and len(word) > 3:
225
- if not word.startswith('*'):
226
- # Make sure the extension looks reasonable (2-4 chars)
227
- parts = word.rsplit('.', 1)
228
- if len(parts) == 2 and 1 <= len(parts[1]) <= 4 and parts[1].isalnum():
229
- return True
303
+ # --- POSITIVE CHECKS ---
230
304
 
231
- # Has a path separator with actual file-looking path components
305
+ # Has extension = very likely a file
306
+ if '.' in word and not word.startswith('.'):
307
+ # Get the extension
308
+ parts = word.rsplit('.', 1)
309
+ if len(parts) == 2:
310
+ name, ext = parts
311
+ # Be more permissive with extensions
312
+ if name and ext and ext.replace('_', '').replace('-', '').isalnum():
313
+ if len(ext) <= 10: # Most extensions are < 10 chars
314
+ return True
315
+
316
+ # Has path separator = could be a file
232
317
  if '/' in word:
233
- parts = word.split('/')
234
- if len(parts) >= 2:
235
- last_part = parts[-1]
236
- # Last part must look like a filename
237
- if last_part and '.' in last_part and not last_part.startswith('*'):
318
+ # Check if it's a path to something specific (not just dirs)
319
+ if not word.endswith('/'): # Not ending with / (directory indicator)
320
+ parts = word.split('/')
321
+ last_part = parts[-1] if parts else ''
322
+
323
+ # If last part has extension, definitely a file
324
+ if '.' in last_part and not last_part.startswith('.'):
325
+ return True
326
+
327
+ # If it's under specific directories that contain files
328
+ if word.startswith('/dev/') and len(word) > 5: # /dev/null, /dev/tty, etc.
329
+ return True
330
+ if word.startswith('/tmp/') and len(word) > 5: # /tmp/anything
331
+ return True
332
+ if word.startswith('/etc/') and len(word) > 5: # /etc/passwd, etc.
333
+ return True
334
+ if word.startswith('/usr/bin/') and len(word) > 9: # Executables
335
+ return True
336
+ if word.startswith('/usr/local/bin/') and len(word) > 15:
337
+ return True
338
+
339
+ # If last part looks like a filename (even without extension)
340
+ if last_part and last_part.replace('-', '').replace('_', '').isalnum():
341
+ # Could be an executable or script
238
342
  return True
239
343
 
240
- # Is a special path to a file (not just directory)
241
- if word.startswith('/dev/') and len(word) > 5:
242
- return True
243
- if word.startswith('/tmp/') and len(word) > 5:
344
+ # Check for well-known files without extensions (case-insensitive)
345
+ filename_only = word.split('/')[-1].lower()
346
+ if filename_only in {'makefile', 'readme', 'license', 'dockerfile',
347
+ 'gemfile', 'rakefile', 'procfile', 'vagrantfile',
348
+ 'jenkinsfile', 'cakefile', 'gulpfile', 'gruntfile',
349
+ 'brewfile', 'berksfile', 'guardfile', 'fastfile',
350
+ 'cartfile', 'appfile', 'podfile', 'snapfile'}:
244
351
  return True
245
352
 
246
- # Check for common file patterns without extensions
247
- filename_only = word.split('/')[-1]
248
- if filename_only in {'Makefile', 'README', 'LICENSE', 'Dockerfile', 'Gemfile', 'Cargo.toml', 'package.json'}:
249
- return True
353
+ # Stand-alone word without path - be conservative
354
+ if '/' not in word:
355
+ # If it has an extension, probably a file in current directory
356
+ if '.' in word and not word.startswith('.'):
357
+ return True
358
+
359
+ # Well-known executable names without extensions
360
+ if word in {'script', 'run', 'build', 'test', 'deploy', 'install',
361
+ 'configure', 'setup', 'bootstrap', 'init'}:
362
+ return True
363
+
364
+ # Otherwise, we can't be sure it's a file (could be a command)
365
+ return False
250
366
 
251
367
  return False
252
368
 
@@ -255,6 +371,7 @@ def _looks_like_file(word: str) -> bool:
255
371
  if __name__ == "__main__":
256
372
  # Test cases
257
373
  test_cases = [
374
+ "cd /Users/user/src/project/server && python test.py",
258
375
  "python a.py | tee b.log",
259
376
  "cat a.txt > /tmp/b.txt",
260
377
  "grep foo file.txt | sort | uniq > output.txt",
@@ -35,24 +35,43 @@ CURSOR_HOOKS = {
35
35
  "name": "beforeShellExecution",
36
36
  "description": "Triggered before a shell command is executed by the agent. "
37
37
  "Allows inspection and potential blocking of shell commands.",
38
- "version": "1.0.0"
38
+ "version": "1.0.0",
39
+ "parameters": '{"type":"object","properties":{"command":{"type":"string","description":"Full terminal '
40
+ 'command"},"cwd":{"type":"string","description":"Current working directory"}},"required":['
41
+ '"command","cwd"],"additionalProperties":false}'
39
42
  },
40
43
  "afterShellExecution": {
41
44
  "name": "afterShellExecution",
42
45
  "description": "Triggered after a shell command completes execution. "
43
46
  "Provides access to command output and exit status.",
44
- "version": "1.0.0"
47
+ "version": "1.0.0",
48
+ "parameters": '{"type":"object","properties":{"command":{"type":"string","description":"Full terminal '
49
+ 'command"},"output":{"type":"string","description":"Full terminal output"}},"required":['
50
+ '"command","output"],"additionalProperties":false}'
45
51
  },
46
52
  "beforeReadFile": {
47
53
  "name": "beforeReadFile",
48
54
  "description": "Triggered before the agent reads a file. "
49
55
  "Allows inspection and potential blocking of file read operations.",
50
- "version": "1.0.0"
56
+ "version": "1.0.0",
57
+ "parameters": '{"type":"object","properties":{"file_path":{"type":"string","description":"Absolute path to '
58
+ 'the file being read"},"content":{"type":"string","description":"File contents"},'
59
+ '"attachments":{"type":"array","description":"Additional related attachments",'
60
+ '"items":{"type":"object","properties":{"type":{"type":"string","description":"Attachment '
61
+ 'type"},"file_path":{"type":"string","description":"Absolute path to the attachment"}},'
62
+ '"required":["type","file_path"],"additionalProperties":false}}},"required":["file_path",'
63
+ '"content"],"additionalProperties":false}'
51
64
  },
52
65
  "beforeSubmitPrompt": {
53
66
  "name": "beforeSubmitPrompt",
54
67
  "description": "Triggered before a prompt is submitted to the AI model. "
55
68
  "Allows inspection and modification of prompts.",
56
- "version": "1.0.0"
69
+ "version": "1.0.0",
70
+ "parameters": '{"type":"object","properties":{"prompt":{"type":"string","description":"User prompt text"},'
71
+ '"attachments":{"type":"array","description":"Attachments associated with the prompt",'
72
+ '"items":{"type":"object","properties":{"type":{"type":"string","enum":["file","rule"],'
73
+ '"description":"Attachment type"},"filePath":{"type":"string","description":"Absolute path to '
74
+ 'the attached file or rule"}},"required":["type","filePath"],"additionalProperties":false}}},'
75
+ '"required":["prompt"],"additionalProperties":false}'
57
76
  }
58
77
  }
@@ -16,6 +16,9 @@ from ide_tools.common.hooks.shell_execution import handle_shell_execution
16
16
  from modules.logs.audit_trail import AuditTrailLogger
17
17
  from modules.logs.logger import MCPLogger
18
18
  from .constants import CURSOR_HOOKS, CURSOR_CONFIG
19
+ from ..common.hooks.output import output_result
20
+
21
+ MASK_AFTER_SHELL_EXEC = True
19
22
 
20
23
 
21
24
  def route_cursor_hook(logger: MCPLogger, audit_logger: AuditTrailLogger, stdin_input: str):
@@ -68,6 +71,7 @@ def route_cursor_hook(logger: MCPLogger, audit_logger: AuditTrailLogger, stdin_i
68
71
  logger=logger,
69
72
  audit_logger=audit_logger,
70
73
  event_id=event_id,
74
+ prompt_id=prompt_id,
71
75
  cwd=cwd,
72
76
  server_name=CURSOR_CONFIG.server_name,
73
77
  client_name="cursor",
@@ -78,9 +82,12 @@ def route_cursor_hook(logger: MCPLogger, audit_logger: AuditTrailLogger, stdin_i
78
82
  handle_shell_execution(logger, audit_logger, stdin_input, prompt_id, event_id, cwd, CURSOR_CONFIG,
79
83
  hook_event_name, is_request=True))
80
84
  elif hook_event_name == "afterShellExecution":
81
- asyncio.run(
82
- handle_shell_execution(logger, audit_logger, stdin_input, prompt_id, event_id, cwd, CURSOR_CONFIG,
83
- hook_event_name, is_request=False))
85
+ if not MASK_AFTER_SHELL_EXEC:
86
+ asyncio.run(
87
+ handle_shell_execution(logger, audit_logger, stdin_input, prompt_id, event_id, cwd, CURSOR_CONFIG,
88
+ hook_event_name, is_request=False))
89
+ else:
90
+ output_result(logger, CURSOR_CONFIG.output_format, "permission", True, "", "")
84
91
  elif hook_event_name == "beforeReadFile":
85
92
  asyncio.run(handle_read_file(logger, audit_logger, stdin_input, prompt_id, event_id, cwd, CURSOR_CONFIG,
86
93
  hook_event_name))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpower-proxy
3
- Version: 0.0.74
3
+ Version: 0.0.79
4
4
  Summary: MCPower Security proxy
5
5
  Author-email: MCPower Security <support@mcpower.tech>
6
6
  License: Apache License
@@ -216,7 +216,7 @@ Requires-Dist: watchdog>=3.0.0
216
216
  Requires-Dist: jsonc-parser>=1.1.5
217
217
  Requires-Dist: jsonpath-ng>=1.7.0
218
218
  Requires-Dist: pydantic>=2.8.0
219
- Requires-Dist: mcpower-shared==0.1.4
219
+ Requires-Dist: mcpower-shared==0.1.5
220
220
  Requires-Dist: bashlex>=0.18
221
221
  Dynamic: license-file
222
222
 
@@ -3,25 +3,25 @@ ide_tools/__init__.py,sha256=odLisqEeKVuZupQoeM-uP9WgQseVTYQG0eP2hhwUyAc,250
3
3
  ide_tools/router.py,sha256=PbGz1cwUXR3Bcy879W2wgmT4f2uThXiXevpWSxcgKZQ,1538
4
4
  ide_tools/common/__init__.py,sha256=cW2cN5y2l4PHGkkWtYgydrtXtmLU_UipJAf_c0VMTk0,65
5
5
  ide_tools/common/hooks/__init__.py,sha256=O-waaJU1OQFxplrLZymSSiIyLDNlGlki6nX-nbJiqz0,61
6
- ide_tools/common/hooks/init.py,sha256=is3MFHyKXhNvlqzlIuQF6fD2Jvm8afJ-p1qqRoKpdqQ,3983
6
+ ide_tools/common/hooks/init.py,sha256=N_MaDGE92qqLaMlqOoavQ2EHWM1MzMudXnO6nmHpGQE,4215
7
7
  ide_tools/common/hooks/output.py,sha256=FBezJL1S5vzLJWW_cLht4XFLDJvhV_w-UTqCQOLEusE,1879
8
- ide_tools/common/hooks/prompt_submit.py,sha256=B_CQXfHPBwy8ATyG8uDHISO5Uk7IQCpEKrTyOK_rKQM,4787
9
- ide_tools/common/hooks/read_file.py,sha256=o4itvHFbSHqwqssPtyJ_PunXK3vaCo8Fxmxkq_TQFF0,6349
10
- ide_tools/common/hooks/shell_execution.py,sha256=n0_naSefI0ZkH5PNA5a7ff1FsDiKNBK-P2-jah7lT1c,9435
11
- ide_tools/common/hooks/shell_parser_bashlex.py,sha256=Jd_VPecFjT0qx9XHbYC6TVeUiwcl5HoSuZxWn8qEMwc,10079
8
+ ide_tools/common/hooks/prompt_submit.py,sha256=_FysGrlJXcblex-OapWVIv1bedP1IKR2d1cvkINU0uM,4907
9
+ ide_tools/common/hooks/read_file.py,sha256=FBHTxOXrQWlw2qtddAq4gfv5h1g6rTbQ-0uqqL9wXE0,6456
10
+ ide_tools/common/hooks/shell_execution.py,sha256=KbJSGLW__bPso45eAlbV13nnkKYwwd7-KodZ6VyUnS0,9522
11
+ ide_tools/common/hooks/shell_parser_bashlex.py,sha256=ePit2jduHvBYkmEvcslpjCGJ5H-amVEm1We1vVCmMNc,15002
12
12
  ide_tools/common/hooks/types.py,sha256=ndisZ8YoasQ3HlVzzyLmpiBCQJs43YPmixbaIIxRpZM,1023
13
13
  ide_tools/common/hooks/utils.py,sha256=7Q95IZZG_lShq5Jvw78M7QN5-tVNVeFzLU33qB8boEU,9425
14
14
  ide_tools/cursor/__init__.py,sha256=YW_V8m0A0bou0wQW_wy3nt2L_7MaNWeNKYBx-NQkilw,153
15
- ide_tools/cursor/constants.py,sha256=KMRBRqxRxTdemMoq81TVG6avt3ATtsPo4aJo8XhSctk,1804
15
+ ide_tools/cursor/constants.py,sha256=xpJXSFgFhTBaznrBj53RZU0WTBSrL884Cnc9W2WDRb4,3778
16
16
  ide_tools/cursor/format.py,sha256=Lh-KH1IlsLL-0B98Bz-ywxpwXL8urK7yPteZGBTPOiY,972
17
- ide_tools/cursor/router.py,sha256=AmRv1kfDEd3EULSlRjj0BDtedlyqtgFgSBp25iMxJBI,3856
18
- mcpower_proxy-0.0.74.dist-info/licenses/LICENSE,sha256=U6WUzdnBrbmVxBmY75ikW-KtinwYnowZ7yNb5hECrvY,11337
17
+ ide_tools/cursor/router.py,sha256=2M7RIGv_8sAPHy-wBoNLjnY45-YOE4ZRklLQjTxIGwM,4138
18
+ mcpower_proxy-0.0.79.dist-info/licenses/LICENSE,sha256=U6WUzdnBrbmVxBmY75ikW-KtinwYnowZ7yNb5hECrvY,11337
19
19
  modules/__init__.py,sha256=mJglXQwSRhU-bBv4LXgfu7NfGN9K4BeQWMPApen5rAA,30
20
20
  modules/decision_handler.py,sha256=P8isKzf4GIWz9SK-VJPtO8VJEgNp7rAIcVZngnaLHmw,9574
21
21
  modules/apis/__init__.py,sha256=Y5WZpKJzHpnRJebk0F80ZRTjR2PpA2LlYLgqI3XlmRo,15
22
22
  modules/apis/security_policy.py,sha256=3fljaTpzfh_Nj0-jVvbnCJBL-CZxvqFNWfSlrAawsBc,15103
23
23
  modules/logs/__init__.py,sha256=dpboUQjuO02z8K-liCbm2DYkCa-CB_ZDV9WSSjNm7Fs,15
24
- modules/logs/audit_trail.py,sha256=_jE9A3WG7ooPUphDHpUFnZ6de16ewhdXYV-tV2__zxo,6183
24
+ modules/logs/audit_trail.py,sha256=yoxzvRDI8ldjC-5o9__PkvaAbM33jWbq_8Sm6uKj8o8,6198
25
25
  modules/logs/logger.py,sha256=MJS0P8VEzUX-5udzQitznaBPCBAcZJCygUgwaDWSq94,4087
26
26
  modules/redaction/__init__.py,sha256=e5NTmp-zonUdzzscih-w_WQ-X8Nvb8CE8b_d6SbrwWg,316
27
27
  modules/redaction/constants.py,sha256=xbDSX8n72FuJu6JJ_sbBE0f5OcWuwEwHxBZuK9Xz-TI,1213
@@ -48,13 +48,15 @@ modules/utils/copy.py,sha256=9OJIqWn8PxPZXr3DTt_01jp0YgmPimckab1969WFh0c,1075
48
48
  modules/utils/ids.py,sha256=rhhRz7RmFjuJGYLft1erHz7vJII1DRpr3iHxBhhFl1s,5743
49
49
  modules/utils/json.py,sha256=OA-JtSBqh9qd1yfm-iyOefNBMH3ITFUdxAkj7O_JZ-Y,4024
50
50
  modules/utils/mcp_configs.py,sha256=DZaujZnF9LlPDJHzyepH7fWSt1GTr-FEmShPCqnZ5aI,1829
51
+ modules/utils/platform.py,sha256=Kz1Dh_UkvMbfXqiyZIEj7INYaWE9wT6nM9WIKXM91uM,552
52
+ modules/utils/string.py,sha256=cOuwWReyBwOgjxtTRPq1R6pCpfkgfqVmjdiaDPufIZU,456
51
53
  wrapper/__init__.py,sha256=OJUsuWSoN1JqIHq4bSrzuL7ufcYJcwAmYCrJjLH44LM,22
52
- wrapper/__version__.py,sha256=xvmhPF9SQGt9OxGn6eSO3lHu3zAUyz_anYm_hLdZ9-8,82
53
- wrapper/middleware.py,sha256=NhLhP9DxtRmzxr6xddmKYoJcjrV4ywyqTXIPcchI040,30040
54
+ wrapper/__version__.py,sha256=98JsmtG5HIwyXB6YPi_uPCHgtqCJD3P7KsFa8zkg6uQ,82
55
+ wrapper/middleware.py,sha256=6FmmyTnyZ61LZgsSCapSOluNX3GwIhyatiRPkPaT5tk,30355
54
56
  wrapper/schema.py,sha256=O-CtKI9eJ4eEnqeUXPCrK7QJAFJrdp_cFbmMyg452Aw,7952
55
57
  wrapper/server.py,sha256=zoIW_bqXV9vKZNFtD-ij0X_LtKJEjucda6lQI5mU6qY,3440
56
- mcpower_proxy-0.0.74.dist-info/METADATA,sha256=BpxAdA-15Na7gf0S-vnUs3VPlPNchrQWAz7ZxPcliiw,15698
57
- mcpower_proxy-0.0.74.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
58
- mcpower_proxy-0.0.74.dist-info/entry_points.txt,sha256=0smL8dxE7ERNz6XEggNaUC3QzKp8mD-v4q5nVEo0MXE,48
59
- mcpower_proxy-0.0.74.dist-info/top_level.txt,sha256=OcCYMHHqbZq3mP5IZDRGdHUNOsKhT1XVnk_mDF_49Es,31
60
- mcpower_proxy-0.0.74.dist-info/RECORD,,
58
+ mcpower_proxy-0.0.79.dist-info/METADATA,sha256=eyGyx3ZiPMCYH2LEu4IiTuJEKXLGXnAlxu1zw5SCKRo,15698
59
+ mcpower_proxy-0.0.79.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
60
+ mcpower_proxy-0.0.79.dist-info/entry_points.txt,sha256=0smL8dxE7ERNz6XEggNaUC3QzKp8mD-v4q5nVEo0MXE,48
61
+ mcpower_proxy-0.0.79.dist-info/top_level.txt,sha256=OcCYMHHqbZq3mP5IZDRGdHUNOsKhT1XVnk_mDF_49Es,31
62
+ mcpower_proxy-0.0.79.dist-info/RECORD,,
@@ -53,6 +53,7 @@ class AuditTrailLogger:
53
53
  self,
54
54
  event_type: str,
55
55
  data: Dict[str, Any],
56
+ *,
56
57
  event_id: Optional[str] = None,
57
58
  prompt_id: Optional[str] = None,
58
59
  user_prompt: Optional[str] = None,
@@ -88,14 +89,14 @@ class AuditTrailLogger:
88
89
  if prompt_id:
89
90
  event["prompt_id"] = prompt_id
90
91
 
91
- # Include user_prompt text if provided (only needed once per prompt_id)
92
- if user_prompt:
93
- event["user_prompt"] = user_prompt
94
-
95
92
  # Include event_id if provided (for pairing request/response)
96
93
  if event_id:
97
94
  event["event_id"] = event_id
98
95
 
96
+ # Include user_prompt text if provided (only needed once per prompt_id)
97
+ if user_prompt:
98
+ event["user_prompt"] = user_prompt
99
+
99
100
  # If app_uid not set yet, queue the log
100
101
  if self.app_uid is None:
101
102
  self._pending_logs.append(event)
@@ -0,0 +1,23 @@
1
+ """Platform detection utilities"""
2
+ import sys
3
+ from typing import Optional, Literal
4
+
5
+
6
+ def get_client_os() -> Optional[Literal["macos", "windows", "linux"]]:
7
+ """
8
+ Fetch Python's sys.platform and convert to standardized OS names.
9
+
10
+ Returns:
11
+ "macos", "windows", "linux", or None if platform is unknown
12
+ """
13
+ platform = sys.platform
14
+
15
+ if platform == "darwin":
16
+ return "macos"
17
+ elif platform == "win32":
18
+ return "windows"
19
+ elif platform == "linux":
20
+ return "linux"
21
+ else:
22
+ return None
23
+
@@ -0,0 +1,17 @@
1
+ """String utility functions"""
2
+
3
+
4
+ def truncate_at(text: str, max_length: int) -> str:
5
+ """
6
+ Truncate string at max_length, appending '...' only if truncated.
7
+
8
+ Args:
9
+ text: String to truncate
10
+ max_length: Maximum length before truncation
11
+
12
+ Returns:
13
+ Truncated string with '...' suffix if truncated, original if not
14
+ """
15
+ if len(text) <= max_length:
16
+ return text
17
+ return f"{text[:max_length]}..."
wrapper/__version__.py CHANGED
@@ -3,4 +3,4 @@
3
3
  Wrapper MCP Server Version
4
4
  """
5
5
 
6
- __version__ = "0.0.74"
6
+ __version__ = "0.0.79"
wrapper/middleware.py CHANGED
@@ -28,6 +28,8 @@ from modules.utils.copy import safe_copy
28
28
  from modules.utils.ids import generate_event_id, get_session_id, read_app_uid, get_project_mcpower_dir
29
29
  from modules.utils.json import safe_json_dumps, to_dict
30
30
  from modules.utils.mcp_configs import extract_wrapped_server_info
31
+ from modules.utils.platform import get_client_os
32
+ from modules.utils.string import truncate_at
31
33
  from wrapper.schema import merge_input_schema_with_existing
32
34
 
33
35
 
@@ -173,7 +175,7 @@ class SecurityMiddleware(Middleware):
173
175
  async def secure_elicitation_handler(self, message, response_type, params, context):
174
176
  # FIXME: elicitation message, params, and context should be redacted before logging
175
177
  self.logger.info(f"secure_elicitation_handler: "
176
- f"message={str(message)[:100]}..., response_type={response_type},"
178
+ f"message={truncate_at(str(message), 100)}, response_type={response_type},"
177
179
  f"params={params}, context={context}")
178
180
 
179
181
  mock_context = MockContext(
@@ -203,7 +205,7 @@ class SecurityMiddleware(Middleware):
203
205
 
204
206
  async def secure_log_handler(self, log_message):
205
207
  # FIXME: log_message should be redacted before logging,
206
- self.logger.info(f"secure_log_handler: {str(log_message)[:100]}...")
208
+ self.logger.info(f"secure_log_handler: {truncate_at(str(log_message), 100)}")
207
209
  # FIXME: log_message should be reviewed with policy before forwarding
208
210
 
209
211
  # Handle case where log_message.data is a string instead of dict
@@ -431,7 +433,8 @@ class SecurityMiddleware(Middleware):
431
433
  for tool in tools:
432
434
  tool_ref = ToolRef(
433
435
  name=getattr(tool, 'name', 'unknown'),
434
- description=getattr(tool, 'description', ''),
436
+ description=f"Description:\n{getattr(tool, 'description', '')}\n\n"
437
+ f"inputSchema:\n{safe_json_dumps(getattr(tool, 'parameters', {}))}",
435
438
  version=getattr(tool, 'version', None)
436
439
  )
437
440
  tool_refs.append(tool_ref)
@@ -654,7 +657,9 @@ class SecurityMiddleware(Middleware):
654
657
  "current_files": wrapper_args.get('__wrapper_currentFiles')
655
658
  },
656
659
  client=self.wrapper_server_name,
657
- client_version=self.wrapper_server_version
660
+ client_version=self.wrapper_server_version,
661
+ client_os=get_client_os(),
662
+ app_id=self.app_id,
658
663
  )
659
664
  }
660
665