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,100 @@
1
+ """
2
+ Cursor Router
3
+
4
+ Routes Cursor hook calls to appropriate handlers.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import sys
10
+ import uuid
11
+
12
+ from ide_tools.common.hooks.init import handle_init
13
+ from ide_tools.common.hooks.prompt_submit import handle_prompt_submit
14
+ from ide_tools.common.hooks.read_file import handle_read_file
15
+ from ide_tools.common.hooks.shell_execution import handle_shell_execution
16
+ from modules.logs.audit_trail import AuditTrailLogger
17
+ from modules.logs.logger import MCPLogger
18
+ from .constants import CURSOR_HOOKS, CURSOR_CONFIG
19
+
20
+
21
+ def route_cursor_hook(logger: MCPLogger, audit_logger: AuditTrailLogger, stdin_input: str):
22
+ """
23
+ Route Cursor hook to appropriate shared handler
24
+
25
+ Args:
26
+ logger: MCPLogger instance
27
+ audit_logger: AuditTrailLogger instance
28
+ stdin_input: Raw input string from stdin
29
+ """
30
+ try:
31
+ input_data = json.loads(stdin_input)
32
+
33
+ hook_event_name = input_data.get("hook_event_name")
34
+ if not hook_event_name:
35
+ logger.error("Missing required field 'hook_event_name' in input")
36
+ sys.exit(1)
37
+
38
+ conversation_id = input_data.get("conversation_id")
39
+ if not conversation_id:
40
+ logger.error("Missing required field 'conversation_id' in input")
41
+ sys.exit(1)
42
+
43
+ generation_id = input_data.get("generation_id")
44
+ if not generation_id:
45
+ logger.error("Missing required field 'generation_id' in input")
46
+ sys.exit(1)
47
+
48
+ workspace_roots = input_data.get("workspace_roots")
49
+ if workspace_roots is None:
50
+ logger.error("Missing required field 'workspace_roots' in input")
51
+ sys.exit(1)
52
+
53
+ if not isinstance(workspace_roots, list):
54
+ logger.error("Invalid 'workspace_roots': must be a list")
55
+ sys.exit(1)
56
+
57
+ prompt_id = conversation_id[:8]
58
+ event_id = uuid.uuid4().hex[:8]
59
+ cwd = workspace_roots[0] if workspace_roots else None
60
+
61
+ logger.info(
62
+ f"Cursor router: routing to {hook_event_name} handler "
63
+ f"(prompt_id={prompt_id}, event_id={event_id}, cwd={cwd})")
64
+
65
+ # Route to appropriate handler
66
+ if hook_event_name == "init":
67
+ asyncio.run(handle_init(
68
+ logger=logger,
69
+ audit_logger=audit_logger,
70
+ event_id=event_id,
71
+ cwd=cwd,
72
+ server_name=CURSOR_CONFIG.server_name,
73
+ client_name="cursor",
74
+ hooks=CURSOR_HOOKS
75
+ ))
76
+ elif hook_event_name == "beforeShellExecution":
77
+ asyncio.run(
78
+ handle_shell_execution(logger, audit_logger, stdin_input, prompt_id, event_id, cwd, CURSOR_CONFIG,
79
+ hook_event_name, is_request=True))
80
+ 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))
84
+ elif hook_event_name == "beforeReadFile":
85
+ asyncio.run(handle_read_file(logger, audit_logger, stdin_input, prompt_id, event_id, cwd, CURSOR_CONFIG,
86
+ hook_event_name))
87
+ elif hook_event_name == "beforeSubmitPrompt":
88
+ asyncio.run(
89
+ handle_prompt_submit(logger, audit_logger, stdin_input, prompt_id, event_id, cwd, CURSOR_CONFIG,
90
+ hook_event_name))
91
+ else:
92
+ logger.error(f"Unknown hook_event_name: {hook_event_name}")
93
+ sys.exit(1)
94
+
95
+ except json.JSONDecodeError as e:
96
+ logger.error(f"Failed to parse input JSON: {e}")
97
+ sys.exit(1)
98
+ except Exception as e:
99
+ logger.error(f"Routing error: {e}", exc_info=True)
100
+ sys.exit(1)
ide_tools/router.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ IDE Tools Router
3
+
4
+ Routes hook calls to appropriate IDE-specific routers based on --ide flag.
5
+ """
6
+
7
+ import sys
8
+ from typing import Optional
9
+
10
+ from modules.logs.audit_trail import AuditTrailLogger
11
+ from modules.logs.logger import MCPLogger
12
+
13
+
14
+ def main(logger: MCPLogger, audit_logger: AuditTrailLogger, ide: str, context: Optional[str]):
15
+ """
16
+ Main entry point for IDE tools - routes to appropriate IDE router
17
+
18
+ Args:
19
+ logger: MCPLogger instance
20
+ audit_logger: AuditTrailLogger instance
21
+ ide: IDE name (e.g., "cursor")
22
+ context: Additional context (to be verified as optional by the associated --ide handler)
23
+ """
24
+ # Monkey patch sys.exit to log exit code
25
+ original_exit = sys.exit
26
+
27
+ def logged_exit(code=0):
28
+ logger.info(f"sys.exit called with code: {code}")
29
+ original_exit(code)
30
+
31
+ sys.exit = logged_exit
32
+
33
+ logger.info(f"IDE Tools router: ide={ide}, context={context}")
34
+
35
+ # Read stdin input once at the top level (raw string)
36
+ # Each handler will parse it according to its own schema
37
+ stdin_input = sys.stdin.read()
38
+
39
+ # Route to appropriate IDE handler with the raw input string
40
+ if ide == "cursor":
41
+ from ide_tools.cursor import route_cursor_hook
42
+ route_cursor_hook(logger, audit_logger, stdin_input)
43
+ elif ide == "claude-code":
44
+ from ide_tools.claude_code import route_claude_code_hook
45
+ route_claude_code_hook(logger, audit_logger, stdin_input)
46
+ else:
47
+ logger.error(f"Unknown IDE: {ide}")
48
+ sys.exit(1)
main.py CHANGED
@@ -45,13 +45,20 @@ def main():
45
45
  logger.info('=' * 66)
46
46
  logger.info('')
47
47
 
48
- # Start config monitoring
49
- config.start_monitoring(logger)
50
-
51
48
  # Setup audit trail logging
52
49
  audit_logger = setup_audit_trail_logger(logger)
53
50
 
54
- logger.info(f"Starting MCPower Proxy:\n{{'args': {args}, 'log_file': {log_file}, 'log_level': {log_level}, 'debug_mode': {debug_mode}}}")
51
+ if args.ide_tool:
52
+ from ide_tools.router import main as ide_tools_main
53
+ ide_tools_main(logger, audit_logger, args.ide, args.context)
54
+ return
55
+
56
+ # Continue with MCP wrapper mode
57
+ # Start config monitoring
58
+ config.start_monitoring(logger)
59
+
60
+ logger.info(
61
+ f"Starting MCPower Proxy:\n{{'args': {args}, 'log_file': {log_file}, 'log_level': {log_level}, 'debug_mode': {debug_mode}}}")
55
62
 
56
63
  try:
57
64
  # Parse JSON/JSONC config
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcpower-proxy
3
- Version: 0.0.58
3
+ Version: 0.0.73
4
4
  Summary: MCPower Security proxy
5
5
  Author-email: MCPower Security <support@mcpower.tech>
6
6
  License: Apache License
@@ -209,20 +209,31 @@ Keywords: mcp,security,proxy,monitoring,audit,redaction,policy-enforcement
209
209
  Requires-Python: ~=3.11.0
210
210
  Description-Content-Type: text/markdown
211
211
  License-File: LICENSE
212
- Requires-Dist: fastmcp>=2.10.5
212
+ Requires-Dist: fastmcp==2.13.0.2
213
213
  Requires-Dist: httpx>=0.25.0
214
214
  Requires-Dist: mcp>=1.0.0
215
215
  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.1
219
+ Requires-Dist: mcpower-shared==0.1.3
220
220
  Dynamic: license-file
221
221
 
222
222
  # MCPower Proxy
223
223
 
224
224
  Real-time semantic monitoring of AI agent<->MCP Server communication to protect from data leaks and malicious prompt injections.
225
225
 
226
+ ## 🚀 How to use
227
+
228
+ The simplest way to use MCPower is to install the VS Code/Cursor extension:
229
+
230
+ - **VS Code Marketplace**: [Install MCPower](https://marketplace.visualstudio.com/items?itemName=mcpower.mcpower)
231
+ - **Open VSX (Cursor & others)**: [Install MCPower](https://open-vsx.org/extension/mcpower/mcpower)
232
+
233
+ The extension automatically installs and protects all your MCP servers - no manual configuration needed!
234
+
235
+ ---
236
+
226
237
  ## Overview
227
238
 
228
239
  MCPower is a semantic policy broker that understands *what* your AI agents are doing, not just *where* they're sending data. It acts as an intelligent security layer that intercepts every MCP tool call made by AI agents, analyzes the payload for sensitive information in real-time, and enforces security policies seamlessly.
@@ -248,3 +259,4 @@ MCPower is built as a Python-based proxy server that wraps MCP servers and provi
248
259
 
249
260
  **VSC Extension**: See [targets/vsc-extension/README.md](targets/vsc-extension/README.md) for installation and user guide
250
261
 
262
+ <!-- mcp-name: io.github.ai-mcpower/mcpower-proxy -->
@@ -0,0 +1,59 @@
1
+ main.py,sha256=MahoqxG5euEJwLhosv2OVUkVbTaMkph_je8lgmtc7Z4,3920
2
+ ide_tools/__init__.py,sha256=odLisqEeKVuZupQoeM-uP9WgQseVTYQG0eP2hhwUyAc,250
3
+ ide_tools/router.py,sha256=PbGz1cwUXR3Bcy879W2wgmT4f2uThXiXevpWSxcgKZQ,1538
4
+ ide_tools/common/__init__.py,sha256=ALhDYMw8v9tlYVEVcx3kP-e6c0yEyRQAQYe7sqwHHB4,66
5
+ ide_tools/common/hooks/__init__.py,sha256=px5Ibzn6rlBcprp7RiZwGWnnWJmGITb0ApiXWKbeyRM,62
6
+ ide_tools/common/hooks/init.py,sha256=QH3qhXqOfQj1IzAXRxgDXu2zDnLHJ-_3PFZWq6Zp-TI,4044
7
+ ide_tools/common/hooks/output.py,sha256=xmOGmPLXy4H3A2G2A95WxqIB4qCWbVO9sq-pz2clyzU,1856
8
+ ide_tools/common/hooks/prompt_submit.py,sha256=-3rwH_e_uywAN9PV_IpSAOEeU21DMJ15PyMLlmwWiPA,7325
9
+ ide_tools/common/hooks/read_file.py,sha256=d8p_IpGwgWG9Vn0yd78KPRzoDi3RacmqxiTaFqndBa0,6481
10
+ ide_tools/common/hooks/shell_execution.py,sha256=uneYaiU8YQhzEjishnSW3SZGdU4Ob0hgSgo7TK5InQw,7154
11
+ ide_tools/common/hooks/types.py,sha256=nT_3rZ7J8b70o2FcY1lkYSFI4Z3WfD7oQgUEGSiFIbQ,1036
12
+ ide_tools/common/hooks/utils.py,sha256=R_jKQdG98oQhooMXV_l2xURIvYgi0Y9UwZyGbcx0LuU,9144
13
+ ide_tools/cursor/__init__.py,sha256=YW_V8m0A0bou0wQW_wy3nt2L_7MaNWeNKYBx-NQkilw,153
14
+ ide_tools/cursor/constants.py,sha256=KMRBRqxRxTdemMoq81TVG6avt3ATtsPo4aJo8XhSctk,1804
15
+ ide_tools/cursor/format.py,sha256=Lh-KH1IlsLL-0B98Bz-ywxpwXL8urK7yPteZGBTPOiY,972
16
+ ide_tools/cursor/router.py,sha256=AmRv1kfDEd3EULSlRjj0BDtedlyqtgFgSBp25iMxJBI,3856
17
+ mcpower_proxy-0.0.73.dist-info/licenses/LICENSE,sha256=U6WUzdnBrbmVxBmY75ikW-KtinwYnowZ7yNb5hECrvY,11337
18
+ modules/__init__.py,sha256=mJglXQwSRhU-bBv4LXgfu7NfGN9K4BeQWMPApen5rAA,30
19
+ modules/decision_handler.py,sha256=P8isKzf4GIWz9SK-VJPtO8VJEgNp7rAIcVZngnaLHmw,9574
20
+ modules/apis/__init__.py,sha256=Y5WZpKJzHpnRJebk0F80ZRTjR2PpA2LlYLgqI3XlmRo,15
21
+ modules/apis/security_policy.py,sha256=3fljaTpzfh_Nj0-jVvbnCJBL-CZxvqFNWfSlrAawsBc,15103
22
+ modules/logs/__init__.py,sha256=dpboUQjuO02z8K-liCbm2DYkCa-CB_ZDV9WSSjNm7Fs,15
23
+ modules/logs/audit_trail.py,sha256=_jE9A3WG7ooPUphDHpUFnZ6de16ewhdXYV-tV2__zxo,6183
24
+ modules/logs/logger.py,sha256=MJS0P8VEzUX-5udzQitznaBPCBAcZJCygUgwaDWSq94,4087
25
+ modules/redaction/__init__.py,sha256=e5NTmp-zonUdzzscih-w_WQ-X8Nvb8CE8b_d6SbrwWg,316
26
+ modules/redaction/constants.py,sha256=xbDSX8n72FuJu6JJ_sbBE0f5OcWuwEwHxBZuK9Xz-TI,1213
27
+ modules/redaction/gitleaks_rules.py,sha256=8dRb4g5OQaHAjx8vpMbxwu06CdDE39aqw9eqLiCDcqY,46411
28
+ modules/redaction/pii_rules.py,sha256=-JhjcCjH5NFeOfQGzTFNdx_-s_0i6tZ-XFxydtkByD0,10019
29
+ modules/redaction/redactor.py,sha256=Y3PSxXJSLJZj8gkKZ3OQ7XxoIPUilFk6gY0dbeqfjwE,23352
30
+ modules/ui/__init__.py,sha256=YlW4XfQEGW1ezg3UFU59nHw95LicmlpNPhim5IqSB50,34
31
+ modules/ui/classes.py,sha256=ZvVRdzO_hD4WnpS3_eVa0WCyaooXiYVpHLzQkzBaH6M,1777
32
+ modules/ui/confirmation.py,sha256=y2A8j5_z64cDdbvUEh4lI8iTgTFBcTjVbIXioMfmkpw,7740
33
+ modules/ui/simple_dialog.py,sha256=PZW3WSPUVtnGXx-Kkg6hTQTr5NvpTQVhgHyro1z_3aY,3900
34
+ modules/ui/xdialog/__init__.py,sha256=KYQKVF6pGrwc99swRBxtWVXM__j9kVX_r6KikzbCOM4,9359
35
+ modules/ui/xdialog/constants.py,sha256=UjtqzT_O3OHUXJOyeTGroOUnaxdVyYukf7kK6vj1rog,200
36
+ modules/ui/xdialog/mac_dialogs.py,sha256=6r3hkJzJJdHSt-aH1Hy4lZ1MEuZK4Kc5D_YiWglKHAA,6129
37
+ modules/ui/xdialog/tk_dialogs.py,sha256=isxxN_mvZUFUQu8RD1J-GC7UMH2spqR3v_domgRbczQ,2403
38
+ modules/ui/xdialog/windows_custom_dialog.py,sha256=tcdo35d4ZoBydAj-4yzzgW2luw97-Sdjsr3X_3-a7jM,14849
39
+ modules/ui/xdialog/windows_dialogs.py,sha256=ohOoK4ciyv2s4BC9r7-zvGL6mECM-RCPTVOmzDnD6VQ,7626
40
+ modules/ui/xdialog/windows_structs.py,sha256=xzG44OGT5hBFnimJgOLXZBhmpQ_9CFxjtz-QNjP-VCw,8698
41
+ modules/ui/xdialog/yad_dialogs.py,sha256=EiajZVJg-xDwYymz1fyQwLtT5DzbJR3e8plMEnOgcpo,6933
42
+ modules/ui/xdialog/zenity_dialogs.py,sha256=wE71I_Ovf0sjhxHVNocbrhhDd8Y8X8loLETp8TMGMPQ,4512
43
+ modules/utils/__init__.py,sha256=Ptwu1epT_dW6EHjGkzGHAB-MbrrmYAlcPXGGcr4PvwE,20
44
+ modules/utils/cli.py,sha256=5QLwsZNMKdyh3xt4NDk20vCGD-eqZ514LnVWA0J0Lbg,2414
45
+ modules/utils/config.py,sha256=YuGrIYfBsOYABWjFoZosObPz-R7Wdul16RnDed_glYI,6654
46
+ modules/utils/copy.py,sha256=9OJIqWn8PxPZXr3DTt_01jp0YgmPimckab1969WFh0c,1075
47
+ modules/utils/ids.py,sha256=rhhRz7RmFjuJGYLft1erHz7vJII1DRpr3iHxBhhFl1s,5743
48
+ modules/utils/json.py,sha256=OA-JtSBqh9qd1yfm-iyOefNBMH3ITFUdxAkj7O_JZ-Y,4024
49
+ modules/utils/mcp_configs.py,sha256=DZaujZnF9LlPDJHzyepH7fWSt1GTr-FEmShPCqnZ5aI,1829
50
+ wrapper/__init__.py,sha256=OJUsuWSoN1JqIHq4bSrzuL7ufcYJcwAmYCrJjLH44LM,22
51
+ wrapper/__version__.py,sha256=rCTF1Ssof33lV_ElQ2Jd40DYgONJlHNMKTaVGHBP-go,82
52
+ wrapper/middleware.py,sha256=77mZ30DApRlcX7__JzNFlqwKvR1mBYQg5izHbpZiNnw,29854
53
+ wrapper/schema.py,sha256=O-CtKI9eJ4eEnqeUXPCrK7QJAFJrdp_cFbmMyg452Aw,7952
54
+ wrapper/server.py,sha256=zoIW_bqXV9vKZNFtD-ij0X_LtKJEjucda6lQI5mU6qY,3440
55
+ mcpower_proxy-0.0.73.dist-info/METADATA,sha256=XZE3Lk26Hb6MLROM7HbBm9CbJApBPcw-N6U_-3eZyak,15669
56
+ mcpower_proxy-0.0.73.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
57
+ mcpower_proxy-0.0.73.dist-info/entry_points.txt,sha256=0smL8dxE7ERNz6XEggNaUC3QzKp8mD-v4q5nVEo0MXE,48
58
+ mcpower_proxy-0.0.73.dist-info/top_level.txt,sha256=OcCYMHHqbZq3mP5IZDRGdHUNOsKhT1XVnk_mDF_49Es,31
59
+ mcpower_proxy-0.0.73.dist-info/RECORD,,
@@ -1,3 +1,4 @@
1
+ ide_tools
1
2
  main
2
3
  modules
3
4
  wrapper
@@ -1,9 +1,9 @@
1
1
  """Security Policy API Client"""
2
2
 
3
3
  import json
4
+ import time
4
5
  import uuid
5
6
  from typing import Dict, Any, Optional, List
6
- import time
7
7
 
8
8
  import httpx
9
9
 
@@ -13,6 +13,7 @@ from modules.logs.logger import MCPLogger
13
13
  from modules.redaction import redact
14
14
  from modules.utils.config import get_api_url, get_user_id
15
15
  from modules.utils.json import safe_json_dumps, to_dict
16
+ from wrapper.__version__ import __version__
16
17
 
17
18
 
18
19
  class SecurityAPIError(Exception):
@@ -22,6 +23,7 @@ class SecurityAPIError(Exception):
22
23
 
23
24
  class RateLimitExhaustedError(SecurityAPIError):
24
25
  """Security API rate limit exhausted (429) error"""
26
+
25
27
  def __init__(self, message: str, retry_after: int = None):
26
28
  super().__init__(message)
27
29
  self.retry_after = retry_after
@@ -52,7 +54,7 @@ class SecurityPolicyClient:
52
54
  if self.client:
53
55
  await self.client.aclose()
54
56
 
55
- async def inspect_policy_request(self, policy_request: PolicyRequest,
57
+ async def inspect_policy_request(self, policy_request: PolicyRequest,
56
58
  prompt_id: str) -> InspectDecision:
57
59
  """Call inspect_policy_request API endpoint"""
58
60
  if not self.client:
@@ -155,7 +157,7 @@ class SecurityPolicyClient:
155
157
  audit_payload = {"payload": {"server": payload_dict["server"], "tools": payload_dict["tools"]}}
156
158
  else:
157
159
  audit_payload = {"payload": payload_dict}
158
-
160
+
159
161
  self.audit_logger.log_event(
160
162
  audit_event_type,
161
163
  audit_payload,
@@ -166,6 +168,7 @@ class SecurityPolicyClient:
166
168
 
167
169
  headers = {
168
170
  "Content-Type": "application/json",
171
+ "User-Agent": f"MCPower-{__version__}",
169
172
  "X-User-UID": self.user_id,
170
173
  "X-App-UID": self.app_id
171
174
  }
@@ -188,7 +191,8 @@ class SecurityPolicyClient:
188
191
  raise SecurityAPIError(f"Unsupported HTTP method: {method}. Supported methods: POST, PUT")
189
192
 
190
193
  on_make_request_duration = time.time() - on_make_request_start_time
191
- self.logger.info(f"PROFILE: {method} id: {id} make_request duration: {on_make_request_duration:.2f} seconds url: {url}")
194
+ self.logger.debug(
195
+ f"PROFILE: {method} id: {id} make_request duration: {on_make_request_duration:.2f} seconds url: {url}")
192
196
 
193
197
  match response.status_code:
194
198
  case 200:
@@ -215,7 +219,7 @@ class SecurityPolicyClient:
215
219
  else:
216
220
  # Other responses (e.g., /init) - log entire response
217
221
  audit_result = {"result": data_dict}
218
-
222
+
219
223
  self.audit_logger.log_event(
220
224
  f"{audit_event_type}_result",
221
225
  audit_result,
@@ -278,7 +282,8 @@ class SecurityPolicyClient:
278
282
  def _handle_quota_restoration(self, endpoint: str):
279
283
  """Handle quota restoration (when non-429 response received)"""
280
284
  if self.session_id in self._session_notification_times:
281
- self.logger.info(f"Quota restored - received successful response from {endpoint}. Session: {self.session_id}")
285
+ self.logger.info(
286
+ f"Quota restored - received successful response from {endpoint}. Session: {self.session_id}")
282
287
  del self._session_notification_times[self.session_id]
283
288
 
284
289
  def _send_throttled_quota_notification(self, retry_after: int, endpoint: str):
@@ -0,0 +1,219 @@
1
+ """
2
+ Decision Handler - Common decision enforcement logic
3
+
4
+ Shared module for enforcing security policy decisions across middleware and IDE tools.
5
+ Handles user confirmation dialogs and decision recording.
6
+ """
7
+ from typing import Dict, Any, Optional
8
+
9
+ from mcpower_shared.mcp_types import UserConfirmation
10
+ from modules.apis.security_policy import SecurityPolicyClient
11
+ from modules.logs.audit_trail import AuditTrailLogger
12
+ from modules.logs.logger import MCPLogger
13
+ from modules.ui.classes import ConfirmationRequest, DialogOptions, UserDecision
14
+ from modules.ui.confirmation import UserConfirmationDialog, UserConfirmationError
15
+
16
+
17
+ class DecisionEnforcementError(Exception):
18
+ """Error raised when a security decision blocks an operation"""
19
+ pass
20
+
21
+
22
+ class DecisionHandler:
23
+ """
24
+ Handles security policy decision enforcement with user confirmation support.
25
+
26
+ This class provides common functionality for:
27
+ - Enforcing allow/block/confirm decisions
28
+ - Showing user confirmation dialogs
29
+ - Recording user decisions via API
30
+ """
31
+
32
+ def __init__(self, logger: MCPLogger, audit_logger: AuditTrailLogger,
33
+ session_id: str, app_id: str):
34
+ self.logger = logger
35
+ self.audit_logger = audit_logger
36
+ self.session_id = session_id
37
+ self.app_id = app_id
38
+
39
+ async def enforce_decision(
40
+ self,
41
+ decision: Dict[str, Any],
42
+ is_request: bool,
43
+ event_id: str,
44
+ tool_name: str,
45
+ content_data: Dict[str, Any],
46
+ operation_type: str,
47
+ prompt_id: str,
48
+ server_name: str,
49
+ error_message_prefix: Optional[str] = None
50
+ ) -> None:
51
+ """
52
+ Enforce security decision with user confirmation support.
53
+
54
+ Args:
55
+ decision: Security decision dict with 'decision', 'reasons', 'severity', 'call_type'
56
+ is_request: True if inspecting request, False if inspecting response
57
+ event_id: Event ID for tracking
58
+ tool_name: Name of the tool/operation
59
+ content_data: Data to show in confirmation dialog
60
+ operation_type: Type of operation (e.g., 'tool', 'hook')
61
+ prompt_id: prompt ID for correlation
62
+ server_name: server name for display
63
+ error_message_prefix: Optional prefix for error messages
64
+
65
+ Raises:
66
+ DecisionEnforcementError: If decision blocks the operation
67
+ """
68
+ decision_type = decision.get("decision", "block")
69
+
70
+ if decision_type == "allow":
71
+ return
72
+
73
+ elif decision_type == "block":
74
+ policy_reasons = decision.get("reasons", ["Policy violation"])
75
+ severity = decision.get("severity", "unknown")
76
+ call_type = decision.get("call_type")
77
+
78
+ try:
79
+ # Show a blocking dialog and wait for user decision
80
+ confirmation_request = ConfirmationRequest(
81
+ is_request=is_request,
82
+ tool_name=tool_name,
83
+ policy_reasons=policy_reasons,
84
+ content_data=content_data,
85
+ severity=severity,
86
+ event_id=event_id,
87
+ operation_type=operation_type,
88
+ server_name=server_name,
89
+ timeout_seconds=60
90
+ )
91
+
92
+ response = UserConfirmationDialog(
93
+ self.logger, self.audit_logger
94
+ ).request_blocking_confirmation(confirmation_request, prompt_id, call_type)
95
+
96
+ # If we got here, user chose "Allow Anyway"
97
+ self.logger.info(f"User chose to 'allow anyway' a blocked {confirmation_request.operation_type} "
98
+ f"operation for tool '{tool_name}' (event: {event_id})")
99
+
100
+ await self._record_user_confirmation(event_id, is_request, response.user_decision, prompt_id, call_type)
101
+ return
102
+
103
+ except UserConfirmationError as e:
104
+ # User chose to block or dialog failed
105
+ await self._record_user_confirmation(event_id, is_request, UserDecision.BLOCK, prompt_id, call_type)
106
+ error_msg = error_message_prefix or "Security Violation"
107
+ raise DecisionEnforcementError(f"{error_msg}. User blocked the operation")
108
+
109
+ elif decision_type == "required_explicit_user_confirmation":
110
+ policy_reasons = decision.get("reasons", ["Security policy requires confirmation"])
111
+ severity = decision.get("severity", "unknown")
112
+ call_type = decision.get("call_type")
113
+
114
+ try:
115
+ confirmation_request = ConfirmationRequest(
116
+ is_request=is_request,
117
+ tool_name=tool_name,
118
+ policy_reasons=policy_reasons,
119
+ content_data=content_data,
120
+ severity=severity,
121
+ event_id=event_id,
122
+ operation_type=operation_type,
123
+ server_name=server_name,
124
+ timeout_seconds=60
125
+ )
126
+
127
+ # only show YES_ALWAYS if call_type exists
128
+ options = DialogOptions(
129
+ show_always_allow=(call_type is not None),
130
+ show_always_block=False
131
+ )
132
+
133
+ response = UserConfirmationDialog(
134
+ self.logger, self.audit_logger
135
+ ).request_confirmation(confirmation_request, prompt_id, call_type, options)
136
+
137
+ # If we got here, user approved the operation
138
+ self.logger.info(f"User {response.user_decision.value} {confirmation_request.operation_type} "
139
+ f"operation for tool '{tool_name}' (event: {event_id})")
140
+
141
+ await self._record_user_confirmation(event_id, is_request, response.user_decision, prompt_id, call_type)
142
+ return
143
+
144
+ except UserConfirmationError as e:
145
+ # User denied confirmation or dialog failed
146
+ await self._record_user_confirmation(event_id, is_request, UserDecision.BLOCK, prompt_id, call_type)
147
+ error_msg = error_message_prefix or "Security Violation"
148
+ raise DecisionEnforcementError(f"{error_msg}. User blocked the operation")
149
+
150
+ elif decision_type == "need_more_info":
151
+ stage_title = 'CLIENT REQUEST' if is_request else 'TOOL RESPONSE'
152
+
153
+ # Create an actionable error message for the AI agent
154
+ reasons = decision.get("reasons", [])
155
+ need_fields = decision.get("need_fields", [])
156
+
157
+ error_parts = [
158
+ f"SECURITY POLICY NEEDS MORE INFORMATION FOR REVIEWING {stage_title}:",
159
+ '\n'.join(reasons),
160
+ '' # newline
161
+ ]
162
+
163
+ if need_fields:
164
+ # Convert server field names to wrapper field names for the AI agent
165
+ wrapper_field_mapping = {
166
+ "context.agent.intent": "__wrapper_modelIntent",
167
+ "context.agent.plan": "__wrapper_modelPlan",
168
+ "context.agent.expectedOutputs": "__wrapper_modelExpectedOutputs",
169
+ "context.agent.user_prompt": "__wrapper_userPrompt",
170
+ "context.agent.user_prompt_id": "__wrapper_userPromptId",
171
+ "context.agent.context_summary": "__wrapper_contextSummary",
172
+ "context.workspace.current_files": "__wrapper_currentFiles",
173
+ }
174
+
175
+ missing_wrapper_fields = []
176
+ for field in need_fields:
177
+ wrapper_field = wrapper_field_mapping.get(field, field)
178
+ missing_wrapper_fields.append(wrapper_field)
179
+
180
+ if missing_wrapper_fields:
181
+ error_parts.append("AFFECTED FIELDS:")
182
+ error_parts.extend(missing_wrapper_fields)
183
+ else:
184
+ error_parts.append("MISSING INFORMATION:")
185
+ error_parts.extend(need_fields)
186
+
187
+ error_parts.append("\nMANDATORY ACTIONS:")
188
+ error_parts.append("1. Add/Edit ALL affected fields according to the required information")
189
+ error_parts.append("2. Retry the tool call")
190
+
191
+ actionable_message = "\n".join(error_parts)
192
+ raise DecisionEnforcementError(actionable_message)
193
+
194
+ async def _record_user_confirmation(
195
+ self,
196
+ event_id: str,
197
+ is_request: bool,
198
+ user_decision: UserDecision,
199
+ prompt_id: str,
200
+ call_type: Optional[str] = None
201
+ ):
202
+ """Record user confirmation decision with the security API"""
203
+ try:
204
+ direction = "request" if is_request else "response"
205
+
206
+ user_confirmation = UserConfirmation(
207
+ event_id=event_id,
208
+ direction=direction,
209
+ user_decision=user_decision,
210
+ call_type=call_type
211
+ )
212
+
213
+ async with SecurityPolicyClient(session_id=self.session_id, logger=self.logger,
214
+ audit_logger=self.audit_logger, app_id=self.app_id) as client:
215
+ result = await client.record_user_confirmation(user_confirmation, prompt_id=prompt_id)
216
+ self.logger.debug(f"User confirmation recorded: {result}")
217
+ except Exception as e:
218
+ # Don't fail the operation if API call fails - just log the error
219
+ self.logger.error(f"Failed to record user confirmation: {e}")
@@ -50,14 +50,14 @@ class AuditTrailLogger:
50
50
  Path(self.audit_file).parent.mkdir(parents=True, exist_ok=True)
51
51
 
52
52
  def log_event(
53
- self,
54
- event_type: str,
55
- data: Dict[str, Any],
56
- event_id: Optional[str] = None,
57
- prompt_id: Optional[str] = None,
58
- user_prompt: Optional[str] = None,
59
- ignored_keys: Optional[List[str]] = None,
60
- include_keys: Optional[List[str]] = None
53
+ self,
54
+ event_type: str,
55
+ data: Dict[str, Any],
56
+ event_id: Optional[str] = None,
57
+ prompt_id: Optional[str] = None,
58
+ user_prompt: Optional[str] = None,
59
+ ignored_keys: Optional[List[str]] = None,
60
+ include_keys: Optional[List[str]] = None
61
61
  ):
62
62
  """
63
63
  Log a single audit event
@@ -74,19 +74,20 @@ class AuditTrailLogger:
74
74
  try:
75
75
  # Convert data to dict structure (handles nested objects, dataclasses, Pydantic models)
76
76
  data_dict = to_dict(data)
77
-
77
+
78
78
  # Build event structure
79
79
  event = {
80
80
  "session_id": self.session_id,
81
81
  "timestamp": datetime.now(timezone.utc).isoformat(),
82
82
  "event_type": event_type,
83
- "data": redact(data_dict, ignored_keys=ignored_keys, include_keys=include_keys) # Redaction with optional key filtering
83
+ "data": redact(data_dict, ignored_keys=ignored_keys, include_keys=include_keys)
84
+ # Redaction with optional key filtering
84
85
  }
85
86
 
86
87
  # Include prompt_id if provided (for grouping by user prompt)
87
88
  if prompt_id:
88
89
  event["prompt_id"] = prompt_id
89
-
90
+
90
91
  # Include user_prompt text if provided (only needed once per prompt_id)
91
92
  if user_prompt:
92
93
  event["user_prompt"] = user_prompt
@@ -118,7 +119,7 @@ class AuditTrailLogger:
118
119
  "app_uid": self.app_uid,
119
120
  **{k: v for k, v in event.items() if k != "app_uid"}
120
121
  }
121
-
122
+
122
123
  # Atomic append to audit trail file
123
124
  with open(self.audit_file, 'a', encoding='utf-8') as f:
124
125
  f.write(safe_json_dumps(event_with_app_uid) + '\n')
@@ -130,17 +131,21 @@ class AuditTrailLogger:
130
131
 
131
132
  This is called by the middleware after workspace roots are available.
132
133
  All queued logs will be written with app_uid as the first key.
134
+ Supports updating app_uid when workspace context changes.
133
135
 
134
136
  Args:
135
137
  app_uid: The application UID from workspace root
136
138
  """
137
- if self.app_uid is not None:
138
- self.logger.warning(f"app_uid already set to {self.app_uid}, ignoring new value {app_uid}")
139
+ if self.app_uid == app_uid:
139
140
  return
140
-
141
+
142
+ if self.app_uid is not None:
143
+ self.logger.info(f"app_uid changed from {self.app_uid} to {app_uid}")
144
+ else:
145
+ self.logger.debug(f"app_uid set to: {app_uid}")
146
+
141
147
  self.app_uid = app_uid
142
- self.logger.debug(f"✅ app_uid set to: {app_uid}")
143
-
148
+
144
149
  # Flush all pending logs
145
150
  if self._pending_logs:
146
151
  self.logger.debug(f"Flushing {len(self._pending_logs)} queued audit logs")