mcpower-proxy 0.0.58__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.
- main.py +112 -0
- mcpower_proxy-0.0.58.dist-info/METADATA +250 -0
- mcpower_proxy-0.0.58.dist-info/RECORD +43 -0
- mcpower_proxy-0.0.58.dist-info/WHEEL +5 -0
- mcpower_proxy-0.0.58.dist-info/entry_points.txt +2 -0
- mcpower_proxy-0.0.58.dist-info/licenses/LICENSE +201 -0
- mcpower_proxy-0.0.58.dist-info/top_level.txt +3 -0
- modules/__init__.py +1 -0
- modules/apis/__init__.py +1 -0
- modules/apis/security_policy.py +322 -0
- modules/logs/__init__.py +1 -0
- modules/logs/audit_trail.py +162 -0
- modules/logs/logger.py +128 -0
- modules/redaction/__init__.py +13 -0
- modules/redaction/constants.py +38 -0
- modules/redaction/gitleaks_rules.py +1268 -0
- modules/redaction/pii_rules.py +271 -0
- modules/redaction/redactor.py +599 -0
- modules/ui/__init__.py +1 -0
- modules/ui/classes.py +48 -0
- modules/ui/confirmation.py +200 -0
- modules/ui/simple_dialog.py +104 -0
- modules/ui/xdialog/__init__.py +249 -0
- modules/ui/xdialog/constants.py +13 -0
- modules/ui/xdialog/mac_dialogs.py +190 -0
- modules/ui/xdialog/tk_dialogs.py +78 -0
- modules/ui/xdialog/windows_custom_dialog.py +426 -0
- modules/ui/xdialog/windows_dialogs.py +250 -0
- modules/ui/xdialog/windows_structs.py +183 -0
- modules/ui/xdialog/yad_dialogs.py +236 -0
- modules/ui/xdialog/zenity_dialogs.py +156 -0
- modules/utils/__init__.py +1 -0
- modules/utils/cli.py +46 -0
- modules/utils/config.py +193 -0
- modules/utils/copy.py +36 -0
- modules/utils/ids.py +160 -0
- modules/utils/json.py +120 -0
- modules/utils/mcp_configs.py +48 -0
- wrapper/__init__.py +1 -0
- wrapper/__version__.py +6 -0
- wrapper/middleware.py +750 -0
- wrapper/schema.py +227 -0
- wrapper/server.py +78 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Security Policy API Client"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Dict, Any, Optional, List
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from mcpower_shared.mcp_types import PolicyRequest, PolicyResponse, InitRequest, UserConfirmation, InspectDecision
|
|
11
|
+
from modules.logs.audit_trail import AuditTrailLogger
|
|
12
|
+
from modules.logs.logger import MCPLogger
|
|
13
|
+
from modules.redaction import redact
|
|
14
|
+
from modules.utils.config import get_api_url, get_user_id
|
|
15
|
+
from modules.utils.json import safe_json_dumps, to_dict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SecurityAPIError(Exception):
|
|
19
|
+
"""Security API communication error"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RateLimitExhaustedError(SecurityAPIError):
|
|
24
|
+
"""Security API rate limit exhausted (429) error"""
|
|
25
|
+
def __init__(self, message: str, retry_after: int = None):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.retry_after = retry_after
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SecurityPolicyClient:
|
|
31
|
+
"""HTTP client for security policy API calls"""
|
|
32
|
+
|
|
33
|
+
# Class-level tracking for 429 notifications per session
|
|
34
|
+
_session_notification_times: Dict[str, float] = {}
|
|
35
|
+
|
|
36
|
+
def __init__(self, session_id: str, logger: MCPLogger, audit_logger: AuditTrailLogger, app_id: str,
|
|
37
|
+
timeout: float = 60.0):
|
|
38
|
+
self.base_url = get_api_url().rstrip('/')
|
|
39
|
+
self.timeout = timeout
|
|
40
|
+
self.client: Optional[httpx.AsyncClient] = None
|
|
41
|
+
self.logger = logger
|
|
42
|
+
self.audit_logger = audit_logger
|
|
43
|
+
self.user_id = get_user_id(logger)
|
|
44
|
+
self.app_id = app_id
|
|
45
|
+
self.session_id = session_id
|
|
46
|
+
|
|
47
|
+
async def __aenter__(self):
|
|
48
|
+
self.client = httpx.AsyncClient(timeout=self.timeout)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
52
|
+
if self.client:
|
|
53
|
+
await self.client.aclose()
|
|
54
|
+
|
|
55
|
+
async def inspect_policy_request(self, policy_request: PolicyRequest,
|
|
56
|
+
prompt_id: str) -> InspectDecision:
|
|
57
|
+
"""Call inspect_policy_request API endpoint"""
|
|
58
|
+
if not self.client:
|
|
59
|
+
raise SecurityAPIError("Client not initialized - use async context manager")
|
|
60
|
+
|
|
61
|
+
return await self._make_request("/inspect_request", policy_request, method="POST",
|
|
62
|
+
redacted_keys=[
|
|
63
|
+
"$.tool.description",
|
|
64
|
+
"$.context.agent.*",
|
|
65
|
+
"$.arguments_redacted.*"
|
|
66
|
+
],
|
|
67
|
+
audit_event_type="inspect_agent_request",
|
|
68
|
+
event_id=policy_request.event_id,
|
|
69
|
+
prompt_id=prompt_id)
|
|
70
|
+
|
|
71
|
+
async def inspect_policy_response(self, policy_response: PolicyResponse,
|
|
72
|
+
prompt_id: str) -> InspectDecision:
|
|
73
|
+
"""Call inspect_policy_response API endpoint"""
|
|
74
|
+
if not self.client:
|
|
75
|
+
raise SecurityAPIError("Client not initialized - use async context manager")
|
|
76
|
+
|
|
77
|
+
return await self._make_request("/inspect_response", policy_response, method="POST",
|
|
78
|
+
redacted_keys=[
|
|
79
|
+
"$.tool.description",
|
|
80
|
+
"$.context.agent.*",
|
|
81
|
+
"$.result_preview.*"
|
|
82
|
+
],
|
|
83
|
+
audit_event_type="inspect_mcp_response",
|
|
84
|
+
event_id=policy_response.event_id,
|
|
85
|
+
prompt_id=prompt_id)
|
|
86
|
+
|
|
87
|
+
async def record_user_confirmation(self, user_confirmation: UserConfirmation,
|
|
88
|
+
prompt_id: str) -> Dict[str, Any]:
|
|
89
|
+
"""Record user confirmation decision"""
|
|
90
|
+
if not self.client:
|
|
91
|
+
raise SecurityAPIError("Client not initialized - use async context manager")
|
|
92
|
+
|
|
93
|
+
return await self._make_request("/user_confirmation", payload=user_confirmation, method="PUT",
|
|
94
|
+
# non existing key to skip redaction completely (nothing to redact here)
|
|
95
|
+
redacted_keys=["$.none"],
|
|
96
|
+
audit_event_type="record_user_confirmation",
|
|
97
|
+
event_id=user_confirmation.event_id,
|
|
98
|
+
prompt_id=prompt_id)
|
|
99
|
+
|
|
100
|
+
async def init_tools(self, init_request: InitRequest, event_id: Optional[str] = None) -> Dict[str, Any]:
|
|
101
|
+
"""Initialize tools with environment, server, and tools data"""
|
|
102
|
+
if not self.client:
|
|
103
|
+
raise SecurityAPIError("Client not initialized - use async context manager")
|
|
104
|
+
|
|
105
|
+
payload = {
|
|
106
|
+
"environment": {
|
|
107
|
+
"session_id": init_request.environment.session_id,
|
|
108
|
+
"workspace": init_request.environment.workspace,
|
|
109
|
+
"client": init_request.environment.client,
|
|
110
|
+
"client_version": init_request.environment.client_version,
|
|
111
|
+
"selection_hash": init_request.environment.selection_hash
|
|
112
|
+
},
|
|
113
|
+
"server": {
|
|
114
|
+
"name": init_request.server.name,
|
|
115
|
+
"transport": init_request.server.transport,
|
|
116
|
+
"version": init_request.server.version
|
|
117
|
+
},
|
|
118
|
+
"tools": [
|
|
119
|
+
{
|
|
120
|
+
"name": tool.name,
|
|
121
|
+
"description": tool.description,
|
|
122
|
+
"version": tool.version
|
|
123
|
+
}
|
|
124
|
+
for tool in init_request.tools
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return await self._make_request("/init", payload, method="POST",
|
|
129
|
+
redacted_keys=["$.tools[*].description"],
|
|
130
|
+
audit_event_type="init_tools",
|
|
131
|
+
event_id=event_id,
|
|
132
|
+
prompt_id=None)
|
|
133
|
+
|
|
134
|
+
async def _make_request(self, endpoint: str, payload: Any, method: str,
|
|
135
|
+
audit_event_type: str, event_id: str = None,
|
|
136
|
+
prompt_id: str = None, redacted_keys: List[str] = None) -> Dict[str, Any]:
|
|
137
|
+
"""Make HTTP request to security API"""
|
|
138
|
+
url = f"{self.base_url}{endpoint}"
|
|
139
|
+
error: Exception = None
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
id = str(uuid.uuid4())[:5]
|
|
143
|
+
|
|
144
|
+
payload_dict = to_dict(payload)
|
|
145
|
+
redacted_payload = redact(payload_dict, include_keys=redacted_keys) if redacted_keys else payload_dict
|
|
146
|
+
redacted_payload_json = safe_json_dumps(redacted_payload)
|
|
147
|
+
self.logger.info(f"Security API request: {{'id': {id}, 'method': {method}, 'url': {url}, "
|
|
148
|
+
f"'payload': {redacted_payload_json}}}")
|
|
149
|
+
|
|
150
|
+
if "arguments_redacted" in payload_dict:
|
|
151
|
+
audit_payload = {"payload": payload_dict["arguments_redacted"]}
|
|
152
|
+
elif "result_preview" in payload_dict:
|
|
153
|
+
audit_payload = {"payload": payload_dict["result_preview"]}
|
|
154
|
+
elif "tools" in payload_dict and "server" in payload_dict:
|
|
155
|
+
audit_payload = {"payload": {"server": payload_dict["server"], "tools": payload_dict["tools"]}}
|
|
156
|
+
else:
|
|
157
|
+
audit_payload = {"payload": payload_dict}
|
|
158
|
+
|
|
159
|
+
self.audit_logger.log_event(
|
|
160
|
+
audit_event_type,
|
|
161
|
+
audit_payload,
|
|
162
|
+
event_id=event_id,
|
|
163
|
+
prompt_id=prompt_id,
|
|
164
|
+
include_keys=redacted_keys
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
headers = {
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
"X-User-UID": self.user_id,
|
|
170
|
+
"X-App-UID": self.app_id
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
on_make_request_start_time = time.time()
|
|
174
|
+
method_upper = method.upper()
|
|
175
|
+
if method_upper == "PUT":
|
|
176
|
+
response = await self.client.put(
|
|
177
|
+
url,
|
|
178
|
+
content=redacted_payload_json,
|
|
179
|
+
headers=headers
|
|
180
|
+
)
|
|
181
|
+
elif method_upper == "POST":
|
|
182
|
+
response = await self.client.post(
|
|
183
|
+
url,
|
|
184
|
+
content=redacted_payload_json,
|
|
185
|
+
headers=headers
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
raise SecurityAPIError(f"Unsupported HTTP method: {method}. Supported methods: POST, PUT")
|
|
189
|
+
|
|
190
|
+
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}")
|
|
192
|
+
|
|
193
|
+
match response.status_code:
|
|
194
|
+
case 200:
|
|
195
|
+
data = response.json()
|
|
196
|
+
data_dict = to_dict(data);
|
|
197
|
+
self.logger.info(f"Security API response: {{'id': {id}, 'data': {data_dict}}}")
|
|
198
|
+
|
|
199
|
+
if "decision" in data_dict:
|
|
200
|
+
# InspectDecision response (/inspect_request, /inspect_response)
|
|
201
|
+
# Extract: decision, call_type, severity, reasons
|
|
202
|
+
audit_result = {"result": {"decision": data_dict["decision"]}}
|
|
203
|
+
if "call_type" in data_dict:
|
|
204
|
+
audit_result["result"]["call_type"] = data_dict["call_type"]
|
|
205
|
+
if "severity" in data_dict:
|
|
206
|
+
audit_result["result"]["severity"] = data_dict["severity"]
|
|
207
|
+
if "reasons" in data_dict:
|
|
208
|
+
audit_result["result"]["reasons"] = data_dict["reasons"]
|
|
209
|
+
elif "user_decision" in data_dict:
|
|
210
|
+
# UserConfirmation response (/user_confirmation)
|
|
211
|
+
# Extract: user_decision, call_type, confirmed_at
|
|
212
|
+
audit_result = {"result": {"user_decision": data_dict["user_decision"]}}
|
|
213
|
+
if "call_type" in data_dict:
|
|
214
|
+
audit_result["result"]["call_type"] = data_dict["call_type"]
|
|
215
|
+
else:
|
|
216
|
+
# Other responses (e.g., /init) - log entire response
|
|
217
|
+
audit_result = {"result": data_dict}
|
|
218
|
+
|
|
219
|
+
self.audit_logger.log_event(
|
|
220
|
+
f"{audit_event_type}_result",
|
|
221
|
+
audit_result,
|
|
222
|
+
event_id=event_id,
|
|
223
|
+
prompt_id=prompt_id,
|
|
224
|
+
include_keys=redacted_keys
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Successful response - handle quota restoration
|
|
228
|
+
self._handle_quota_restoration(endpoint)
|
|
229
|
+
|
|
230
|
+
return data
|
|
231
|
+
case 400:
|
|
232
|
+
error_data = response.json()
|
|
233
|
+
error_msg = error_data.get("error", "Bad request")
|
|
234
|
+
raise SecurityAPIError(f"Security API validation error: {error_msg}")
|
|
235
|
+
case 429:
|
|
236
|
+
error_data = response.json() if response.content else {}
|
|
237
|
+
retry_after = int(response.headers.get('Retry-After', '60'))
|
|
238
|
+
|
|
239
|
+
# Handle 429 - log, notify, and return allow decision (screening bypassed)
|
|
240
|
+
self.logger.error(f"Security API rate limit exhausted (429) - bypassing security screening. "
|
|
241
|
+
f"Endpoint: {endpoint}, Retry-After: {retry_after}s, Session: {self.session_id}")
|
|
242
|
+
self._send_throttled_quota_notification(retry_after, endpoint)
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"decision": "allow",
|
|
246
|
+
"severity": "high",
|
|
247
|
+
"reasons": ["Security quota exhausted - screening bypassed"]
|
|
248
|
+
}
|
|
249
|
+
case 500:
|
|
250
|
+
error_data = response.json()
|
|
251
|
+
error_msg = error_data.get("error", "Internal server error")
|
|
252
|
+
raise SecurityAPIError(f"Security API server error: {error_msg}")
|
|
253
|
+
case _:
|
|
254
|
+
raise SecurityAPIError(f"Security API returned status {response.status_code}")
|
|
255
|
+
|
|
256
|
+
except httpx.RequestError as e:
|
|
257
|
+
error = e
|
|
258
|
+
raise SecurityAPIError(f"Failed to connect to security API: {e}")
|
|
259
|
+
except json.JSONDecodeError as e:
|
|
260
|
+
error = e
|
|
261
|
+
raise SecurityAPIError(f"Invalid JSON response from security API: {e}")
|
|
262
|
+
except Exception as e:
|
|
263
|
+
error = e
|
|
264
|
+
raise SecurityAPIError(f"Unexpected error calling security API: {e}")
|
|
265
|
+
finally:
|
|
266
|
+
if error:
|
|
267
|
+
self.audit_logger.log_event(
|
|
268
|
+
f"{audit_event_type}_result",
|
|
269
|
+
{
|
|
270
|
+
"endpoint": endpoint,
|
|
271
|
+
"error": [f"Security API error: {error}"]
|
|
272
|
+
},
|
|
273
|
+
event_id=event_id,
|
|
274
|
+
prompt_id=prompt_id,
|
|
275
|
+
include_keys=redacted_keys
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _handle_quota_restoration(self, endpoint: str):
|
|
279
|
+
"""Handle quota restoration (when non-429 response received)"""
|
|
280
|
+
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}")
|
|
282
|
+
del self._session_notification_times[self.session_id]
|
|
283
|
+
|
|
284
|
+
def _send_throttled_quota_notification(self, retry_after: int, endpoint: str):
|
|
285
|
+
"""Send throttled quota notification to user"""
|
|
286
|
+
import time
|
|
287
|
+
from modules.ui import xdialog
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
current_time = time.time()
|
|
291
|
+
one_hour = 3600
|
|
292
|
+
|
|
293
|
+
# Check if we should send notification (throttle to once per hour per session)
|
|
294
|
+
last_notification = self._session_notification_times.get(self.session_id)
|
|
295
|
+
should_send = (
|
|
296
|
+
last_notification is None or
|
|
297
|
+
(current_time - last_notification) >= one_hour
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if not should_send:
|
|
301
|
+
time_since_last = current_time - last_notification
|
|
302
|
+
self.logger.debug(f"429 notification throttled (sent {time_since_last:.0f}s ago). "
|
|
303
|
+
f"Session: {self.session_id}, Endpoint: {endpoint}")
|
|
304
|
+
else:
|
|
305
|
+
message = (
|
|
306
|
+
"MCPower quota exhausted.\n\n"
|
|
307
|
+
"Subsequent requests will not be screened.\n\n"
|
|
308
|
+
"Please contact support if you need additional quota.\n\n"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
xdialog.warning(
|
|
312
|
+
title="Warning: Security Quota Exhausted",
|
|
313
|
+
message=message
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
self._session_notification_times[self.session_id] = current_time
|
|
317
|
+
|
|
318
|
+
self.logger.warning(f"Displayed 429 quota exhaustion dialog to user. "
|
|
319
|
+
f"Session: {self.session_id}, Endpoint: {endpoint}")
|
|
320
|
+
|
|
321
|
+
except Exception as e:
|
|
322
|
+
self.logger.error(f"Failed to show quota exhaustion notification: {e}")
|
modules/logs/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Logs package
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audit Trail Logger for MCP Wrapper
|
|
3
|
+
|
|
4
|
+
Provides comprehensive transparency and accountability by logging all data flows
|
|
5
|
+
through the MCP wrapper in a user-facing, sequential JSON Lines format.
|
|
6
|
+
|
|
7
|
+
Captures complete request/response lifecycles including:
|
|
8
|
+
- Wrapper initialization
|
|
9
|
+
- Agent requests and policy decisions
|
|
10
|
+
- User confirmation interactions
|
|
11
|
+
- Data forwarding and responses
|
|
12
|
+
- API communications with policy service
|
|
13
|
+
|
|
14
|
+
All data is automatically redacted for PII and secrets before logging.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from modules.logs.logger import MCPLogger
|
|
22
|
+
from modules.redaction.redactor import redact
|
|
23
|
+
from modules.utils.config import get_audit_trail_path
|
|
24
|
+
from modules.utils.ids import get_session_id
|
|
25
|
+
from modules.utils.json import safe_json_dumps, to_dict
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuditTrailLogger:
|
|
29
|
+
"""
|
|
30
|
+
Audit trail logger for transparent MCP wrapper operations
|
|
31
|
+
|
|
32
|
+
Logs all data flows through the wrapper in JSON Lines format for user transparency.
|
|
33
|
+
Each log entry represents one step in the sequential flow of MCP operations.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, logger: MCPLogger):
|
|
37
|
+
"""
|
|
38
|
+
Initialize audit trail logger
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
logger: Existing MCPLogger instance for error reporting
|
|
42
|
+
"""
|
|
43
|
+
self.logger = logger
|
|
44
|
+
self.app_uid: Optional[str] = None # Will be set by middleware after roots are available
|
|
45
|
+
self.session_id = get_session_id()
|
|
46
|
+
self.audit_file = get_audit_trail_path()
|
|
47
|
+
self._pending_logs: List[Dict[str, Any]] = [] # Queue logs until app_uid is set
|
|
48
|
+
|
|
49
|
+
# Ensure audit trail file directory exists
|
|
50
|
+
Path(self.audit_file).parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
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
|
|
61
|
+
):
|
|
62
|
+
"""
|
|
63
|
+
Log a single audit event
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
event_type: Type of audit event (e.g., 'mcpower_start', 'agent_request')
|
|
67
|
+
data: Event-specific data dictionary (will be automatically redacted)
|
|
68
|
+
event_id: Optional event correlation ID (for pairing request/response)
|
|
69
|
+
prompt_id: Optional user prompt correlation ID (for grouping tool calls by prompt)
|
|
70
|
+
user_prompt: Optional user prompt text (stored once per prompt_id)
|
|
71
|
+
ignored_keys: Optional list of JSONPath patterns to ignore during redaction
|
|
72
|
+
include_keys: Optional list of JSONPath patterns to redact (all others ignored)
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
# Convert data to dict structure (handles nested objects, dataclasses, Pydantic models)
|
|
76
|
+
data_dict = to_dict(data)
|
|
77
|
+
|
|
78
|
+
# Build event structure
|
|
79
|
+
event = {
|
|
80
|
+
"session_id": self.session_id,
|
|
81
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
82
|
+
"event_type": event_type,
|
|
83
|
+
"data": redact(data_dict, ignored_keys=ignored_keys, include_keys=include_keys) # Redaction with optional key filtering
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Include prompt_id if provided (for grouping by user prompt)
|
|
87
|
+
if prompt_id:
|
|
88
|
+
event["prompt_id"] = prompt_id
|
|
89
|
+
|
|
90
|
+
# Include user_prompt text if provided (only needed once per prompt_id)
|
|
91
|
+
if user_prompt:
|
|
92
|
+
event["user_prompt"] = user_prompt
|
|
93
|
+
|
|
94
|
+
# Include event_id if provided (for pairing request/response)
|
|
95
|
+
if event_id:
|
|
96
|
+
event["event_id"] = event_id
|
|
97
|
+
|
|
98
|
+
# If app_uid not set yet, queue the log
|
|
99
|
+
if self.app_uid is None:
|
|
100
|
+
self._pending_logs.append(event)
|
|
101
|
+
else:
|
|
102
|
+
# app_uid is available, write immediately
|
|
103
|
+
self._write_event(event)
|
|
104
|
+
|
|
105
|
+
except Exception as e:
|
|
106
|
+
# Log errors to existing logger but continue operation
|
|
107
|
+
self.logger.error(f"Audit trail write failed: {e}")
|
|
108
|
+
|
|
109
|
+
def _write_event(self, event: Dict[str, Any]):
|
|
110
|
+
"""
|
|
111
|
+
Write a single event to the audit trail file with app_uid as first key
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
event: Event dict (may or may not have app_uid already)
|
|
115
|
+
"""
|
|
116
|
+
# Ensure app_uid is first key in the output
|
|
117
|
+
event_with_app_uid = {
|
|
118
|
+
"app_uid": self.app_uid,
|
|
119
|
+
**{k: v for k, v in event.items() if k != "app_uid"}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# Atomic append to audit trail file
|
|
123
|
+
with open(self.audit_file, 'a', encoding='utf-8') as f:
|
|
124
|
+
f.write(safe_json_dumps(event_with_app_uid) + '\n')
|
|
125
|
+
f.flush() # Force immediate write for crash safety
|
|
126
|
+
|
|
127
|
+
def set_app_uid(self, app_uid: str):
|
|
128
|
+
"""
|
|
129
|
+
Set the app_uid and flush all pending logs to file
|
|
130
|
+
|
|
131
|
+
This is called by the middleware after workspace roots are available.
|
|
132
|
+
All queued logs will be written with app_uid as the first key.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
app_uid: The application UID from workspace root
|
|
136
|
+
"""
|
|
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
|
+
return
|
|
140
|
+
|
|
141
|
+
self.app_uid = app_uid
|
|
142
|
+
self.logger.debug(f"✅ app_uid set to: {app_uid}")
|
|
143
|
+
|
|
144
|
+
# Flush all pending logs
|
|
145
|
+
if self._pending_logs:
|
|
146
|
+
self.logger.debug(f"Flushing {len(self._pending_logs)} queued audit logs")
|
|
147
|
+
for event in self._pending_logs:
|
|
148
|
+
self._write_event(event)
|
|
149
|
+
self._pending_logs.clear()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def setup_audit_trail_logger(logger: MCPLogger) -> AuditTrailLogger:
|
|
153
|
+
"""
|
|
154
|
+
Create audit trail logger instance
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
logger: Existing MCPLogger instance for error reporting
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Configured AuditTrailLogger instance
|
|
161
|
+
"""
|
|
162
|
+
return AuditTrailLogger(logger)
|
modules/logs/logger.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple line-based logger for MCP traffic and wrapped MCP logs
|
|
3
|
+
Implements the required logging format for all MCP operations
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, TextIO
|
|
10
|
+
|
|
11
|
+
from modules.utils.ids import get_session_id
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UTF8StreamHandler(logging.StreamHandler):
|
|
15
|
+
"""StreamHandler that forces UTF-8 encoding on Windows to prevent UnicodeEncodeError"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, stream=None):
|
|
18
|
+
# On Windows, wrap the stream with UTF-8 encoding BEFORE passing to parent
|
|
19
|
+
if sys.platform == 'win32' and stream is not None:
|
|
20
|
+
import io
|
|
21
|
+
if hasattr(stream, 'buffer'):
|
|
22
|
+
stream = io.TextIOWrapper(
|
|
23
|
+
stream.buffer,
|
|
24
|
+
encoding='utf-8',
|
|
25
|
+
errors='replace',
|
|
26
|
+
line_buffering=True
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
super().__init__(stream)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionFormatter(logging.Formatter):
|
|
33
|
+
"""Custom formatter that includes session ID in log messages"""
|
|
34
|
+
|
|
35
|
+
# Single character mapping for perfect alignment and compactness
|
|
36
|
+
LEVEL_MAPPING = {
|
|
37
|
+
'DEBUG': 'D',
|
|
38
|
+
'INFO': 'I',
|
|
39
|
+
'WARNING': 'W',
|
|
40
|
+
'ERROR': 'E',
|
|
41
|
+
'CRITICAL': 'C'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def __init__(self, *args, **kwargs):
|
|
45
|
+
super().__init__(*args, **kwargs)
|
|
46
|
+
self._session_id = get_session_id()[:8]
|
|
47
|
+
|
|
48
|
+
def format(self, record):
|
|
49
|
+
# Use the cached session ID
|
|
50
|
+
record.session_id = self._session_id
|
|
51
|
+
# Override levelname with single character version
|
|
52
|
+
if not record.levelname or not isinstance(record.levelname, str):
|
|
53
|
+
record.levelname = 'U' # Unknown
|
|
54
|
+
else:
|
|
55
|
+
record.levelname = self.LEVEL_MAPPING.get(record.levelname, record.levelname[0])
|
|
56
|
+
return super().format(record)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MCPLogger:
|
|
60
|
+
"""Simple line-based logger for MCP traffic"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, log_file: Optional[str] = None, level: int = logging.INFO):
|
|
63
|
+
self.log_file = log_file
|
|
64
|
+
self.file_handle: Optional[TextIO] = None
|
|
65
|
+
|
|
66
|
+
# Setup file handle if log file specified (for MCP traffic logging)
|
|
67
|
+
if log_file:
|
|
68
|
+
log_path = Path(log_file)
|
|
69
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self.file_handle = open(log_path, 'a', encoding='utf-8')
|
|
71
|
+
|
|
72
|
+
# Setup standard logger for non-MCP messages
|
|
73
|
+
self.logger = logging.getLogger('mcpower')
|
|
74
|
+
self.logger.setLevel(level)
|
|
75
|
+
|
|
76
|
+
# Create console handler with UTF-8 support
|
|
77
|
+
console_handler = UTF8StreamHandler(sys.stderr)
|
|
78
|
+
console_handler.setLevel(level)
|
|
79
|
+
formatter = SessionFormatter('%(asctime)s [%(session_id)s] (%(levelname)s) %(message)s')
|
|
80
|
+
console_handler.setFormatter(formatter)
|
|
81
|
+
self.logger.addHandler(console_handler)
|
|
82
|
+
|
|
83
|
+
# Add file handler if log file specified
|
|
84
|
+
if log_file:
|
|
85
|
+
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
|
86
|
+
file_handler.setLevel(level)
|
|
87
|
+
file_handler.setFormatter(formatter)
|
|
88
|
+
self.logger.addHandler(file_handler)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def info(self, message: str) -> None:
|
|
92
|
+
"""Log info message"""
|
|
93
|
+
self.logger.info(message)
|
|
94
|
+
|
|
95
|
+
def error(self, message: str, exc_info: bool = False) -> None:
|
|
96
|
+
"""Log error message"""
|
|
97
|
+
self.logger.error(message, exc_info=exc_info)
|
|
98
|
+
|
|
99
|
+
def warning(self, message: str) -> None:
|
|
100
|
+
"""Log warning message"""
|
|
101
|
+
self.logger.warning(message)
|
|
102
|
+
|
|
103
|
+
def debug(self, message: str) -> None:
|
|
104
|
+
"""Log debug message"""
|
|
105
|
+
self.logger.debug(message)
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
"""Close log file handle"""
|
|
109
|
+
if self.file_handle:
|
|
110
|
+
self.file_handle.close()
|
|
111
|
+
self.file_handle = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def setup_logger(log_file: Optional[str] = None, level: int = logging.INFO) -> MCPLogger:
|
|
115
|
+
"""
|
|
116
|
+
Setup MCP logger with specified configuration
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
log_file: Optional path to log file (uses stdout if None)
|
|
120
|
+
level: Logging level
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Configured MCPLogger instance
|
|
124
|
+
"""
|
|
125
|
+
return MCPLogger(log_file, level)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client-side data redaction module for PII and secrets detection.
|
|
3
|
+
|
|
4
|
+
Uses regex patterns with zero external dependencies:
|
|
5
|
+
- Custom regex patterns for PII detection
|
|
6
|
+
- Gitleaks-based patterns for secrets detection
|
|
7
|
+
|
|
8
|
+
Fully offline, deterministic, and idempotent.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .redactor import redact
|
|
12
|
+
|
|
13
|
+
__all__ = ['redact']
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Constants for client-side redaction.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# PII entity type to placeholder mappings
|
|
6
|
+
PII_PLACEHOLDERS = {
|
|
7
|
+
"CREDIT_CARD": "[REDACTED-CREDIT-CARD]",
|
|
8
|
+
"CRYPTO_ADDRESS": "[REDACTED-CRYPTO]",
|
|
9
|
+
"EMAIL_ADDRESS": "[REDACTED-EMAIL]",
|
|
10
|
+
"IBAN": "[REDACTED-IBAN]",
|
|
11
|
+
"IP_ADDRESS": "[REDACTED-IP]",
|
|
12
|
+
"LOCATION": "[REDACTED-LOCATION]",
|
|
13
|
+
"PERSON": "[REDACTED-PERSON]",
|
|
14
|
+
"PHONE_NUMBER": "[REDACTED-PHONE]",
|
|
15
|
+
"MEDICAL_LICENSE": "[REDACTED-MEDICAL-LICENSE]",
|
|
16
|
+
"URL": "[REDACTED-URL]",
|
|
17
|
+
"US_BANK_NUMBER": "[REDACTED-BANK-NUMBER]",
|
|
18
|
+
"US_DRIVER_LICENSE": "[REDACTED-DRIVER-LICENSE]",
|
|
19
|
+
"US_ITIN": "[REDACTED-ITIN]",
|
|
20
|
+
"US_PASSPORT": "[REDACTED-PASSPORT]",
|
|
21
|
+
"US_SSN": "[REDACTED-SSN]",
|
|
22
|
+
# Default fallback for any other entity types
|
|
23
|
+
"DEFAULT": "[REDACTED-PII]"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Secrets detection placeholder
|
|
27
|
+
SECRETS_PLACEHOLDER = "[REDACTED-SECRET]"
|
|
28
|
+
|
|
29
|
+
# Pattern to match existing redaction placeholders (for idempotency)
|
|
30
|
+
REDACTION_PLACEHOLDER_PATTERN = r'\[REDACTED-[A-Z-]+\]'
|
|
31
|
+
|
|
32
|
+
# Zero-width characters to normalize
|
|
33
|
+
ZERO_WIDTH_CHARS = [
|
|
34
|
+
'\u200b', # Zero Width Space
|
|
35
|
+
'\u200c', # Zero Width Non-Joiner
|
|
36
|
+
'\u200d', # Zero Width Joiner
|
|
37
|
+
'\ufeff', # Zero Width No-Break Space (BOM)
|
|
38
|
+
]
|