read-no-evil-mcp 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- read_no_evil_mcp/__init__.py +36 -0
- read_no_evil_mcp/__main__.py +6 -0
- read_no_evil_mcp/accounts/__init__.py +21 -0
- read_no_evil_mcp/accounts/config.py +93 -0
- read_no_evil_mcp/accounts/credentials/__init__.py +6 -0
- read_no_evil_mcp/accounts/credentials/base.py +29 -0
- read_no_evil_mcp/accounts/credentials/env.py +44 -0
- read_no_evil_mcp/accounts/permissions.py +87 -0
- read_no_evil_mcp/accounts/service.py +109 -0
- read_no_evil_mcp/config.py +98 -0
- read_no_evil_mcp/email/__init__.py +6 -0
- read_no_evil_mcp/email/connectors/__init__.py +6 -0
- read_no_evil_mcp/email/connectors/base.py +148 -0
- read_no_evil_mcp/email/connectors/imap.py +288 -0
- read_no_evil_mcp/email/connectors/smtp.py +110 -0
- read_no_evil_mcp/exceptions.py +44 -0
- read_no_evil_mcp/mailbox.py +329 -0
- read_no_evil_mcp/models.py +88 -0
- read_no_evil_mcp/protection/__init__.py +6 -0
- read_no_evil_mcp/protection/heuristic.py +82 -0
- read_no_evil_mcp/protection/service.py +110 -0
- read_no_evil_mcp/py.typed +0 -0
- read_no_evil_mcp/server.py +12 -0
- read_no_evil_mcp/tools/__init__.py +16 -0
- read_no_evil_mcp/tools/_app.py +6 -0
- read_no_evil_mcp/tools/_service.py +54 -0
- read_no_evil_mcp/tools/delete_email.py +24 -0
- read_no_evil_mcp/tools/get_email.py +64 -0
- read_no_evil_mcp/tools/list_accounts.py +20 -0
- read_no_evil_mcp/tools/list_emails.py +47 -0
- read_no_evil_mcp/tools/list_folders.py +22 -0
- read_no_evil_mcp/tools/move_email.py +29 -0
- read_no_evil_mcp/tools/send_email.py +43 -0
- read_no_evil_mcp-0.2.0.dist-info/METADATA +361 -0
- read_no_evil_mcp-0.2.0.dist-info/RECORD +38 -0
- read_no_evil_mcp-0.2.0.dist-info/WHEEL +4 -0
- read_no_evil_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- read_no_evil_mcp-0.2.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Secure mailbox with prompt injection protection and permission enforcement."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, timedelta
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
|
|
6
|
+
from read_no_evil_mcp.accounts.permissions import AccountPermissions
|
|
7
|
+
from read_no_evil_mcp.email.connectors.base import BaseConnector
|
|
8
|
+
from read_no_evil_mcp.exceptions import PermissionDeniedError
|
|
9
|
+
from read_no_evil_mcp.models import Email, EmailSummary, Folder, ScanResult
|
|
10
|
+
from read_no_evil_mcp.protection.service import ProtectionService
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PromptInjectionError(Exception):
|
|
14
|
+
"""Raised when prompt injection is detected in email content."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, scan_result: ScanResult, email_uid: int, folder: str) -> None:
|
|
17
|
+
self.scan_result = scan_result
|
|
18
|
+
self.email_uid = email_uid
|
|
19
|
+
self.folder = folder
|
|
20
|
+
patterns = ", ".join(scan_result.detected_patterns)
|
|
21
|
+
super().__init__(
|
|
22
|
+
f"Prompt injection detected in email {folder}/{email_uid}. "
|
|
23
|
+
f"Detected patterns: {patterns}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SecureMailbox:
|
|
28
|
+
"""Secure email access with prompt injection protection and permission enforcement.
|
|
29
|
+
|
|
30
|
+
Wraps a BaseConnector and scans email content before returning it.
|
|
31
|
+
Blocks emails that contain detected prompt injection attacks.
|
|
32
|
+
Enforces account permissions on all operations.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
connector: BaseConnector,
|
|
38
|
+
permissions: AccountPermissions,
|
|
39
|
+
protection: ProtectionService | None = None,
|
|
40
|
+
from_address: str | None = None,
|
|
41
|
+
from_name: str | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize secure mailbox.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
connector: Email connector for fetching and optionally sending emails.
|
|
47
|
+
permissions: Account permissions to enforce.
|
|
48
|
+
protection: Protection service for scanning. Defaults to standard service.
|
|
49
|
+
from_address: Sender email address for outgoing emails (e.g., "user@example.com").
|
|
50
|
+
from_name: Optional display name for sender (e.g., "Atlas").
|
|
51
|
+
"""
|
|
52
|
+
self._connector = connector
|
|
53
|
+
self._permissions = permissions
|
|
54
|
+
self._protection = protection or ProtectionService()
|
|
55
|
+
self._from_address = from_address
|
|
56
|
+
self._from_name = from_name
|
|
57
|
+
|
|
58
|
+
def _require_read(self) -> None:
|
|
59
|
+
"""Check if read access is allowed.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
PermissionDeniedError: If read access is denied.
|
|
63
|
+
"""
|
|
64
|
+
if not self._permissions.read:
|
|
65
|
+
raise PermissionDeniedError("Read access denied for this account")
|
|
66
|
+
|
|
67
|
+
def _require_folder(self, folder: str) -> None:
|
|
68
|
+
"""Check if access to a specific folder is allowed.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
folder: The folder name to check access for.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
PermissionDeniedError: If access to the folder is denied.
|
|
75
|
+
"""
|
|
76
|
+
if self._permissions.folders is not None and folder not in self._permissions.folders:
|
|
77
|
+
raise PermissionDeniedError(f"Access to folder '{folder}' denied")
|
|
78
|
+
|
|
79
|
+
def _require_move(self) -> None:
|
|
80
|
+
"""Check if move access is allowed.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
PermissionDeniedError: If move access is denied.
|
|
84
|
+
"""
|
|
85
|
+
if not self._permissions.move:
|
|
86
|
+
raise PermissionDeniedError("Move access denied for this account")
|
|
87
|
+
|
|
88
|
+
def _filter_allowed_folders(self, folders: list[Folder]) -> list[Folder]:
|
|
89
|
+
"""Filter folders to only include those allowed by permissions.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
folders: List of folders to filter.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of folders that are allowed by permissions.
|
|
96
|
+
"""
|
|
97
|
+
if self._permissions.folders is None:
|
|
98
|
+
return folders
|
|
99
|
+
return [f for f in folders if f.name in self._permissions.folders]
|
|
100
|
+
|
|
101
|
+
def _require_send(self) -> None:
|
|
102
|
+
"""Check if send access is allowed.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
PermissionDeniedError: If send access is denied.
|
|
106
|
+
"""
|
|
107
|
+
if not self._permissions.send:
|
|
108
|
+
raise PermissionDeniedError("Send access denied for this account")
|
|
109
|
+
|
|
110
|
+
def connect(self) -> None:
|
|
111
|
+
"""Connect to the email server."""
|
|
112
|
+
self._connector.connect()
|
|
113
|
+
|
|
114
|
+
def disconnect(self) -> None:
|
|
115
|
+
"""Disconnect from the email server."""
|
|
116
|
+
self._connector.disconnect()
|
|
117
|
+
|
|
118
|
+
def __enter__(self) -> "SecureMailbox":
|
|
119
|
+
"""Enter context manager, connecting to the server."""
|
|
120
|
+
self.connect()
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
def __exit__(
|
|
124
|
+
self,
|
|
125
|
+
exc_type: type[BaseException] | None,
|
|
126
|
+
exc_val: BaseException | None,
|
|
127
|
+
exc_tb: TracebackType | None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Exit context manager, disconnecting from the server."""
|
|
130
|
+
self.disconnect()
|
|
131
|
+
|
|
132
|
+
def list_folders(self) -> list[Folder]:
|
|
133
|
+
"""List all available folders.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of Folder objects.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
PermissionDeniedError: If read access is denied.
|
|
140
|
+
"""
|
|
141
|
+
self._require_read()
|
|
142
|
+
folders = self._connector.list_folders()
|
|
143
|
+
return self._filter_allowed_folders(folders)
|
|
144
|
+
|
|
145
|
+
def _scan_summary(self, summary: EmailSummary) -> ScanResult:
|
|
146
|
+
"""Scan email summary fields for prompt injection.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
summary: Email summary to scan.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
ScanResult from scanning subject and sender.
|
|
153
|
+
"""
|
|
154
|
+
parts: list[str] = [summary.subject]
|
|
155
|
+
|
|
156
|
+
if summary.sender.name:
|
|
157
|
+
parts.append(summary.sender.name)
|
|
158
|
+
parts.append(summary.sender.address)
|
|
159
|
+
|
|
160
|
+
combined = "\n".join(parts)
|
|
161
|
+
return self._protection.scan(combined)
|
|
162
|
+
|
|
163
|
+
def fetch_emails(
|
|
164
|
+
self,
|
|
165
|
+
folder: str = "INBOX",
|
|
166
|
+
*,
|
|
167
|
+
lookback: timedelta,
|
|
168
|
+
from_date: date | None = None,
|
|
169
|
+
limit: int | None = None,
|
|
170
|
+
) -> list[EmailSummary]:
|
|
171
|
+
"""Fetch email summaries from a folder with protection scanning.
|
|
172
|
+
|
|
173
|
+
Scans subject and sender fields for prompt injection.
|
|
174
|
+
Emails with detected attacks are filtered out.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
folder: Folder to fetch from (default: INBOX)
|
|
178
|
+
lookback: How far back to look
|
|
179
|
+
from_date: Starting point for lookback (default: today)
|
|
180
|
+
limit: Maximum number of emails to return
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
List of safe EmailSummary objects, newest first.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
PermissionDeniedError: If read access is denied or folder is not allowed.
|
|
187
|
+
"""
|
|
188
|
+
self._require_read()
|
|
189
|
+
self._require_folder(folder)
|
|
190
|
+
|
|
191
|
+
summaries = self._connector.fetch_emails(
|
|
192
|
+
folder,
|
|
193
|
+
lookback=lookback,
|
|
194
|
+
from_date=from_date,
|
|
195
|
+
limit=limit,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
safe_summaries: list[EmailSummary] = []
|
|
199
|
+
for summary in summaries:
|
|
200
|
+
scan_result = self._scan_summary(summary)
|
|
201
|
+
if not scan_result.is_blocked:
|
|
202
|
+
safe_summaries.append(summary)
|
|
203
|
+
|
|
204
|
+
return safe_summaries
|
|
205
|
+
|
|
206
|
+
def get_email(self, folder: str, uid: int) -> Email | None:
|
|
207
|
+
"""Get full email content by UID with protection scanning.
|
|
208
|
+
|
|
209
|
+
Scans email content for prompt injection attacks before returning.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
folder: Folder containing the email
|
|
213
|
+
uid: Unique identifier of the email
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Full Email object or None if not found.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
PermissionDeniedError: If read access is denied or folder is not allowed.
|
|
220
|
+
PromptInjectionError: If prompt injection is detected.
|
|
221
|
+
"""
|
|
222
|
+
self._require_read()
|
|
223
|
+
self._require_folder(folder)
|
|
224
|
+
|
|
225
|
+
email = self._connector.get_email(folder, uid)
|
|
226
|
+
|
|
227
|
+
if email is None:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
# Build content to scan: subject, sender, body
|
|
231
|
+
parts: list[str] = [email.subject]
|
|
232
|
+
|
|
233
|
+
if email.sender.name:
|
|
234
|
+
parts.append(email.sender.name)
|
|
235
|
+
parts.append(email.sender.address)
|
|
236
|
+
|
|
237
|
+
if email.body_plain:
|
|
238
|
+
parts.append(email.body_plain)
|
|
239
|
+
if email.body_html:
|
|
240
|
+
parts.append(email.body_html)
|
|
241
|
+
|
|
242
|
+
combined = "\n".join(parts)
|
|
243
|
+
scan_result = self._protection.scan(combined)
|
|
244
|
+
|
|
245
|
+
if scan_result.is_blocked:
|
|
246
|
+
raise PromptInjectionError(scan_result, uid, folder)
|
|
247
|
+
|
|
248
|
+
return email
|
|
249
|
+
|
|
250
|
+
def send_email(
|
|
251
|
+
self,
|
|
252
|
+
to: list[str],
|
|
253
|
+
subject: str,
|
|
254
|
+
body: str,
|
|
255
|
+
cc: list[str] | None = None,
|
|
256
|
+
reply_to: str | None = None,
|
|
257
|
+
) -> bool:
|
|
258
|
+
"""Send an email.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
to: List of recipient email addresses.
|
|
262
|
+
subject: Email subject line.
|
|
263
|
+
body: Email body text (plain text).
|
|
264
|
+
cc: Optional list of CC recipients.
|
|
265
|
+
reply_to: Optional Reply-To email address.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
True if email was sent successfully.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
PermissionDeniedError: If send access is denied.
|
|
272
|
+
RuntimeError: If sending is not supported by the connector.
|
|
273
|
+
"""
|
|
274
|
+
self._require_send()
|
|
275
|
+
|
|
276
|
+
if not self._connector.can_send():
|
|
277
|
+
raise RuntimeError("Sending not configured for this account")
|
|
278
|
+
|
|
279
|
+
if not self._from_address:
|
|
280
|
+
raise RuntimeError("From address not configured for this account")
|
|
281
|
+
|
|
282
|
+
return self._connector.send(
|
|
283
|
+
from_address=self._from_address,
|
|
284
|
+
to=to,
|
|
285
|
+
subject=subject,
|
|
286
|
+
body=body,
|
|
287
|
+
from_name=self._from_name,
|
|
288
|
+
cc=cc,
|
|
289
|
+
reply_to=reply_to,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def move_email(self, folder: str, uid: int, target_folder: str) -> bool:
|
|
293
|
+
"""Move an email to a target folder.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
folder: Folder containing the email
|
|
297
|
+
uid: Unique identifier of the email
|
|
298
|
+
target_folder: Destination folder to move the email to
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
True if successful, False if email not found.
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
PermissionDeniedError: If move access is denied or folder is not allowed.
|
|
305
|
+
"""
|
|
306
|
+
self._require_move()
|
|
307
|
+
self._require_folder(folder)
|
|
308
|
+
self._require_folder(target_folder)
|
|
309
|
+
|
|
310
|
+
return self._connector.move_email(folder, uid, target_folder)
|
|
311
|
+
|
|
312
|
+
def delete_email(self, folder: str, uid: int) -> bool:
|
|
313
|
+
"""Delete an email by UID.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
folder: Folder containing the email
|
|
317
|
+
uid: Unique identifier of the email
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
True if email was deleted successfully, False otherwise.
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
PermissionDeniedError: If delete access is denied or folder is not allowed.
|
|
324
|
+
"""
|
|
325
|
+
if not self._permissions.delete:
|
|
326
|
+
raise PermissionDeniedError("Delete access denied for this account")
|
|
327
|
+
self._require_folder(folder)
|
|
328
|
+
|
|
329
|
+
return self._connector.delete_email(folder, uid)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Data models for read-no-evil-mcp."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, SecretStr
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class IMAPConfig(BaseModel):
|
|
9
|
+
"""IMAP server configuration."""
|
|
10
|
+
|
|
11
|
+
host: str
|
|
12
|
+
port: int = 993
|
|
13
|
+
username: str
|
|
14
|
+
password: SecretStr
|
|
15
|
+
ssl: bool = True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SMTPConfig(BaseModel):
|
|
19
|
+
"""SMTP server configuration."""
|
|
20
|
+
|
|
21
|
+
host: str
|
|
22
|
+
port: int = 587
|
|
23
|
+
username: str
|
|
24
|
+
password: SecretStr
|
|
25
|
+
ssl: bool = False # False = use STARTTLS, True = use SSL
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EmailAddress(BaseModel):
|
|
29
|
+
"""Parsed email address with optional display name."""
|
|
30
|
+
|
|
31
|
+
name: str | None = None
|
|
32
|
+
address: str
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
if self.name:
|
|
36
|
+
return f"{self.name} <{self.address}>"
|
|
37
|
+
return self.address
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Folder(BaseModel):
|
|
41
|
+
"""IMAP folder/mailbox."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
delimiter: str = "/"
|
|
45
|
+
flags: list[str] = []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Attachment(BaseModel):
|
|
49
|
+
"""Email attachment metadata (content not included)."""
|
|
50
|
+
|
|
51
|
+
filename: str
|
|
52
|
+
content_type: str
|
|
53
|
+
size: int | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EmailSummary(BaseModel):
|
|
57
|
+
"""Lightweight email representation for list views."""
|
|
58
|
+
|
|
59
|
+
uid: int
|
|
60
|
+
folder: str
|
|
61
|
+
subject: str
|
|
62
|
+
sender: EmailAddress
|
|
63
|
+
date: datetime
|
|
64
|
+
has_attachments: bool = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Email(EmailSummary):
|
|
68
|
+
"""Full email content."""
|
|
69
|
+
|
|
70
|
+
to: list[EmailAddress] = []
|
|
71
|
+
cc: list[EmailAddress] = []
|
|
72
|
+
body_plain: str | None = None
|
|
73
|
+
body_html: str | None = None
|
|
74
|
+
attachments: list[Attachment] = []
|
|
75
|
+
message_id: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ScanResult(BaseModel):
|
|
79
|
+
"""Result of scanning content for prompt injection attacks."""
|
|
80
|
+
|
|
81
|
+
is_safe: bool
|
|
82
|
+
score: float # 0.0 = safe, 1.0 = definitely malicious
|
|
83
|
+
detected_patterns: list[str] = []
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_blocked(self) -> bool:
|
|
87
|
+
"""Return True if content should be blocked."""
|
|
88
|
+
return not self.is_safe
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Heuristic scanner for prompt injection detection.
|
|
2
|
+
|
|
3
|
+
Uses the ProtectAI DeBERTa model with PyTorch for ML-based
|
|
4
|
+
prompt injection detection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
from transformers import Pipeline, pipeline
|
|
11
|
+
|
|
12
|
+
from read_no_evil_mcp.models import ScanResult
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger()
|
|
15
|
+
|
|
16
|
+
# Model for prompt injection detection
|
|
17
|
+
MODEL_ID = "protectai/deberta-v3-base-prompt-injection-v2"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HeuristicScanner:
|
|
21
|
+
"""Scanner for prompt injection detection using ProtectAI's DeBERTa model."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, threshold: float = 0.5) -> None:
|
|
24
|
+
"""Initialize scanner with the prompt injection detection model.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
threshold: Detection threshold (0.0-1.0). Scores above this are
|
|
28
|
+
considered prompt injection. Defaults to 0.5.
|
|
29
|
+
"""
|
|
30
|
+
self._threshold = threshold
|
|
31
|
+
self._classifier: Pipeline | None = None # Lazy load
|
|
32
|
+
|
|
33
|
+
def _get_classifier(self) -> Any:
|
|
34
|
+
"""Lazy load the classifier to avoid slow startup."""
|
|
35
|
+
if self._classifier is None:
|
|
36
|
+
logger.debug("Loading prompt injection model", model=MODEL_ID)
|
|
37
|
+
self._classifier = pipeline(
|
|
38
|
+
"text-classification",
|
|
39
|
+
model=MODEL_ID,
|
|
40
|
+
truncation=True,
|
|
41
|
+
max_length=512,
|
|
42
|
+
)
|
|
43
|
+
logger.debug("Model loaded successfully")
|
|
44
|
+
return self._classifier
|
|
45
|
+
|
|
46
|
+
def scan(self, content: str) -> ScanResult:
|
|
47
|
+
"""Scan content for prompt injection patterns.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
content: Text content to scan.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
ScanResult with detection details.
|
|
54
|
+
"""
|
|
55
|
+
if not content:
|
|
56
|
+
return ScanResult(
|
|
57
|
+
is_safe=True,
|
|
58
|
+
score=0.0,
|
|
59
|
+
detected_patterns=[],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
classifier = self._get_classifier()
|
|
63
|
+
result = classifier(content)[0]
|
|
64
|
+
|
|
65
|
+
# Model returns label "INJECTION" or "SAFE" with a score
|
|
66
|
+
is_injection = result["label"] == "INJECTION"
|
|
67
|
+
score: float = result["score"] if is_injection else 1.0 - result["score"]
|
|
68
|
+
|
|
69
|
+
is_safe = score < self._threshold
|
|
70
|
+
detected_patterns: list[str] = []
|
|
71
|
+
|
|
72
|
+
if not is_safe:
|
|
73
|
+
detected_patterns.append("prompt_injection")
|
|
74
|
+
logger.warning("Detected prompt injection", injection_score=score)
|
|
75
|
+
else:
|
|
76
|
+
logger.debug("No prompt injection detected", highest_score=score)
|
|
77
|
+
|
|
78
|
+
return ScanResult(
|
|
79
|
+
is_safe=is_safe,
|
|
80
|
+
score=score,
|
|
81
|
+
detected_patterns=detected_patterns,
|
|
82
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Protection service for orchestrating security scanning."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from html.parser import HTMLParser
|
|
5
|
+
|
|
6
|
+
from read_no_evil_mcp.models import ScanResult
|
|
7
|
+
from read_no_evil_mcp.protection.heuristic import HeuristicScanner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _HTMLTextExtractor(HTMLParser):
|
|
11
|
+
"""Extract plain text from HTML content."""
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
super().__init__()
|
|
15
|
+
self._text_parts: list[str] = []
|
|
16
|
+
|
|
17
|
+
def handle_data(self, data: str) -> None:
|
|
18
|
+
self._text_parts.append(data)
|
|
19
|
+
|
|
20
|
+
def get_text(self) -> str:
|
|
21
|
+
return " ".join(self._text_parts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def strip_html_tags(html: str) -> str:
|
|
25
|
+
"""Strip HTML tags and return plain text content.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
html: HTML content to strip.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Plain text extracted from HTML.
|
|
32
|
+
"""
|
|
33
|
+
parser = _HTMLTextExtractor()
|
|
34
|
+
parser.feed(html)
|
|
35
|
+
text = parser.get_text()
|
|
36
|
+
# Normalize whitespace
|
|
37
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProtectionService:
|
|
41
|
+
"""Orchestrates content scanning for prompt injection attacks.
|
|
42
|
+
|
|
43
|
+
Delegates to HeuristicScanner which uses ProtectAI's DeBERTa model
|
|
44
|
+
for ML-based prompt injection detection.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, scanner: HeuristicScanner | None = None) -> None:
|
|
48
|
+
"""Initialize the protection service.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
scanner: Heuristic scanner to use. Defaults to standard scanner.
|
|
52
|
+
"""
|
|
53
|
+
self._scanner = scanner or HeuristicScanner()
|
|
54
|
+
|
|
55
|
+
def scan(self, content: str) -> ScanResult:
|
|
56
|
+
"""Scan content for prompt injection attacks.
|
|
57
|
+
|
|
58
|
+
Automatically strips HTML tags if content contains HTML markup.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
content: Text content to scan (email body, subject, etc.)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
ScanResult indicating if content is safe.
|
|
65
|
+
"""
|
|
66
|
+
if not content:
|
|
67
|
+
return ScanResult(is_safe=True, score=0.0, detected_patterns=[])
|
|
68
|
+
|
|
69
|
+
# Strip HTML tags if content looks like HTML
|
|
70
|
+
if "<" in content and ">" in content:
|
|
71
|
+
content = strip_html_tags(content)
|
|
72
|
+
if not content:
|
|
73
|
+
return ScanResult(is_safe=True, score=0.0, detected_patterns=[])
|
|
74
|
+
|
|
75
|
+
return self._scanner.scan(content)
|
|
76
|
+
|
|
77
|
+
def scan_email_content(
|
|
78
|
+
self,
|
|
79
|
+
subject: str | None = None,
|
|
80
|
+
body_plain: str | None = None,
|
|
81
|
+
body_html: str | None = None,
|
|
82
|
+
) -> ScanResult:
|
|
83
|
+
"""Scan all email content fields.
|
|
84
|
+
|
|
85
|
+
Combines subject and body content for scanning. HTML content is
|
|
86
|
+
automatically stripped by scan().
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
subject: Email subject line.
|
|
90
|
+
body_plain: Plain text body.
|
|
91
|
+
body_html: HTML body (will be stripped automatically).
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
ScanResult with combined detection results.
|
|
95
|
+
"""
|
|
96
|
+
# Combine all content for scanning
|
|
97
|
+
parts: list[str] = []
|
|
98
|
+
|
|
99
|
+
if subject:
|
|
100
|
+
parts.append(subject)
|
|
101
|
+
if body_plain:
|
|
102
|
+
parts.append(body_plain)
|
|
103
|
+
if body_html:
|
|
104
|
+
parts.append(body_html)
|
|
105
|
+
|
|
106
|
+
if not parts:
|
|
107
|
+
return ScanResult(is_safe=True, score=0.0, detected_patterns=[])
|
|
108
|
+
|
|
109
|
+
combined = "\n".join(parts)
|
|
110
|
+
return self.scan(combined)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""MCP tools package.
|
|
2
|
+
|
|
3
|
+
This module re-exports the shared FastMCP instance and imports all tools
|
|
4
|
+
to trigger their registration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from read_no_evil_mcp.tools import delete_email as _delete_email # noqa: F401
|
|
8
|
+
from read_no_evil_mcp.tools import get_email as _get_email # noqa: F401
|
|
9
|
+
from read_no_evil_mcp.tools import list_accounts as _list_accounts # noqa: F401
|
|
10
|
+
from read_no_evil_mcp.tools import list_emails as _list_emails # noqa: F401
|
|
11
|
+
from read_no_evil_mcp.tools import list_folders as _list_folders # noqa: F401
|
|
12
|
+
from read_no_evil_mcp.tools import move_email as _move_email # noqa: F401
|
|
13
|
+
from read_no_evil_mcp.tools import send_email as _send_email # noqa: F401
|
|
14
|
+
from read_no_evil_mcp.tools._app import mcp
|
|
15
|
+
|
|
16
|
+
__all__ = ["mcp"]
|