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.
Files changed (38) hide show
  1. read_no_evil_mcp/__init__.py +36 -0
  2. read_no_evil_mcp/__main__.py +6 -0
  3. read_no_evil_mcp/accounts/__init__.py +21 -0
  4. read_no_evil_mcp/accounts/config.py +93 -0
  5. read_no_evil_mcp/accounts/credentials/__init__.py +6 -0
  6. read_no_evil_mcp/accounts/credentials/base.py +29 -0
  7. read_no_evil_mcp/accounts/credentials/env.py +44 -0
  8. read_no_evil_mcp/accounts/permissions.py +87 -0
  9. read_no_evil_mcp/accounts/service.py +109 -0
  10. read_no_evil_mcp/config.py +98 -0
  11. read_no_evil_mcp/email/__init__.py +6 -0
  12. read_no_evil_mcp/email/connectors/__init__.py +6 -0
  13. read_no_evil_mcp/email/connectors/base.py +148 -0
  14. read_no_evil_mcp/email/connectors/imap.py +288 -0
  15. read_no_evil_mcp/email/connectors/smtp.py +110 -0
  16. read_no_evil_mcp/exceptions.py +44 -0
  17. read_no_evil_mcp/mailbox.py +329 -0
  18. read_no_evil_mcp/models.py +88 -0
  19. read_no_evil_mcp/protection/__init__.py +6 -0
  20. read_no_evil_mcp/protection/heuristic.py +82 -0
  21. read_no_evil_mcp/protection/service.py +110 -0
  22. read_no_evil_mcp/py.typed +0 -0
  23. read_no_evil_mcp/server.py +12 -0
  24. read_no_evil_mcp/tools/__init__.py +16 -0
  25. read_no_evil_mcp/tools/_app.py +6 -0
  26. read_no_evil_mcp/tools/_service.py +54 -0
  27. read_no_evil_mcp/tools/delete_email.py +24 -0
  28. read_no_evil_mcp/tools/get_email.py +64 -0
  29. read_no_evil_mcp/tools/list_accounts.py +20 -0
  30. read_no_evil_mcp/tools/list_emails.py +47 -0
  31. read_no_evil_mcp/tools/list_folders.py +22 -0
  32. read_no_evil_mcp/tools/move_email.py +29 -0
  33. read_no_evil_mcp/tools/send_email.py +43 -0
  34. read_no_evil_mcp-0.2.0.dist-info/METADATA +361 -0
  35. read_no_evil_mcp-0.2.0.dist-info/RECORD +38 -0
  36. read_no_evil_mcp-0.2.0.dist-info/WHEEL +4 -0
  37. read_no_evil_mcp-0.2.0.dist-info/entry_points.txt +2 -0
  38. 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,6 @@
1
+ """Protection service for prompt injection detection."""
2
+
3
+ from read_no_evil_mcp.protection.heuristic import HeuristicScanner
4
+ from read_no_evil_mcp.protection.service import ProtectionService
5
+
6
+ __all__ = ["HeuristicScanner", "ProtectionService"]
@@ -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,12 @@
1
+ """MCP server implementation for read-no-evil-mcp using FastMCP."""
2
+
3
+ from read_no_evil_mcp.tools import mcp
4
+
5
+
6
+ def main() -> None:
7
+ """Entry point for the MCP server."""
8
+ mcp.run()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -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"]
@@ -0,0 +1,6 @@
1
+ """Shared FastMCP application instance."""
2
+
3
+ from fastmcp import FastMCP
4
+
5
+ # Create the shared FastMCP server instance
6
+ mcp = FastMCP(name="read-no-evil-mcp")