prooflayer-runtime 0.1.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 (45) hide show
  1. prooflayer/__init__.py +50 -0
  2. prooflayer/cli.py +362 -0
  3. prooflayer/config/__init__.py +6 -0
  4. prooflayer/config/allowlist.py +138 -0
  5. prooflayer/config/loader.py +29 -0
  6. prooflayer/detection/__init__.py +21 -0
  7. prooflayer/detection/engine.py +783 -0
  8. prooflayer/detection/models.py +49 -0
  9. prooflayer/detection/normalizer.py +245 -0
  10. prooflayer/detection/rules.py +104 -0
  11. prooflayer/detection/scanner.py +160 -0
  12. prooflayer/detection/scorer.py +65 -0
  13. prooflayer/detection/semantic.py +73 -0
  14. prooflayer/metrics.py +266 -0
  15. prooflayer/reporting/__init__.py +5 -0
  16. prooflayer/reporting/reporter.py +190 -0
  17. prooflayer/response/__init__.py +6 -0
  18. prooflayer/response/actions.py +152 -0
  19. prooflayer/response/killer.py +73 -0
  20. prooflayer/rules/command-injection.yaml +123 -0
  21. prooflayer/rules/data-exfiltration.yaml +83 -0
  22. prooflayer/rules/jailbreaks.yaml +67 -0
  23. prooflayer/rules/prompt-injection.yaml +99 -0
  24. prooflayer/rules/role-manipulation.yaml +60 -0
  25. prooflayer/rules/sql-injection.yaml +51 -0
  26. prooflayer/rules/ssrf-xxe.yaml +51 -0
  27. prooflayer/rules/tool-poisoning.yaml +46 -0
  28. prooflayer/runtime/__init__.py +21 -0
  29. prooflayer/runtime/interceptor.py +91 -0
  30. prooflayer/runtime/mcp_wrapper.py +395 -0
  31. prooflayer/runtime/middleware.py +86 -0
  32. prooflayer/runtime/transport.py +306 -0
  33. prooflayer/runtime/wrapper.py +265 -0
  34. prooflayer/utils/__init__.py +21 -0
  35. prooflayer/utils/encoding.py +87 -0
  36. prooflayer/utils/entropy.py +51 -0
  37. prooflayer/utils/logging.py +86 -0
  38. prooflayer/utils/masking.py +72 -0
  39. prooflayer/version.py +6 -0
  40. prooflayer_runtime-0.1.0.dist-info/METADATA +266 -0
  41. prooflayer_runtime-0.1.0.dist-info/RECORD +45 -0
  42. prooflayer_runtime-0.1.0.dist-info/WHEEL +5 -0
  43. prooflayer_runtime-0.1.0.dist-info/entry_points.txt +2 -0
  44. prooflayer_runtime-0.1.0.dist-info/licenses/LICENSE +4 -0
  45. prooflayer_runtime-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,395 @@
1
+ """
2
+ ProofLayer MCP SDK Wrapper
3
+ ===========================
4
+
5
+ Async-compatible wrapper that integrates with the real MCP Python SDK.
6
+ Intercepts tool calls and tool listings for security scanning.
7
+
8
+ Usage:
9
+ from prooflayer.runtime.mcp_wrapper import ProofLayerMCPWrapper
10
+
11
+ wrapper = ProofLayerMCPWrapper(config=config)
12
+ protected_server = wrapper.wrap(mcp_server)
13
+
14
+ Requires: pip install prooflayer-runtime[mcp]
15
+ """
16
+
17
+ import logging
18
+ import functools
19
+ from typing import Any, Dict, List, Optional, Sequence
20
+
21
+ from ..detection.engine import DetectionEngine
22
+ from ..detection.rules import RuleLoadError
23
+ from ..response.actions import ResponseAction, ThreatAction
24
+ from ..reporting.reporter import SecurityReporter
25
+ from ..config.loader import ConfigLoader
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Lazy import for MCP SDK — it's an optional dependency
30
+ _mcp_available = None
31
+
32
+
33
+ def _check_mcp_available():
34
+ """Check if the MCP SDK is installed, caching the result."""
35
+ global _mcp_available
36
+ if _mcp_available is None:
37
+ try:
38
+ import mcp # noqa: F401
39
+ _mcp_available = True
40
+ except ImportError:
41
+ _mcp_available = False
42
+ return _mcp_available
43
+
44
+
45
+ class MCPDependencyError(ImportError):
46
+ """Raised when the MCP SDK is required but not installed."""
47
+ pass
48
+
49
+
50
+ class ProofLayerMCPWrapper:
51
+ """
52
+ Async-compatible security wrapper for MCP Python SDK servers.
53
+
54
+ Intercepts:
55
+ - call_tool: scans tool call arguments before execution, scans outputs after
56
+ - list_tools: scans tool descriptions for prompt injection (tool poisoning)
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ config: Optional[Dict[str, Any]] = None,
62
+ config_path: Optional[str] = None,
63
+ detection_rules_dir: Optional[str] = None,
64
+ action_on_threat: str = "block",
65
+ report_dir: Optional[str] = None,
66
+ scan_tool_descriptions: bool = True,
67
+ scan_tool_outputs: bool = True,
68
+ fail_closed: bool = True,
69
+ ):
70
+ """
71
+ Initialize the MCP wrapper.
72
+
73
+ Args:
74
+ config: Configuration dict (takes precedence over config_path)
75
+ config_path: Path to YAML config file
76
+ detection_rules_dir: Directory containing YAML rule files
77
+ action_on_threat: Action on threat detection ("allow", "warn", "block", "kill")
78
+ report_dir: Directory for security reports
79
+ scan_tool_descriptions: Scan tool descriptions for prompt injection
80
+ scan_tool_outputs: Scan tool outputs before returning to LLM
81
+ fail_closed: Block all requests if rules fail to load (default True)
82
+ """
83
+ if not _check_mcp_available():
84
+ raise MCPDependencyError(
85
+ "The 'mcp' package is required for MCP SDK integration. "
86
+ "Install it with: pip install prooflayer-runtime[mcp]"
87
+ )
88
+
89
+ # Build config from file or dict, with parameter overrides
90
+ if config:
91
+ self.config = config
92
+ elif config_path:
93
+ self.config = ConfigLoader.load(config_path)
94
+ else:
95
+ self.config = self._default_config()
96
+
97
+ # Apply parameter overrides
98
+ detection_cfg = self.config.setdefault("detection", {})
99
+ response_cfg = self.config.setdefault("response", {})
100
+
101
+ if detection_rules_dir:
102
+ detection_cfg["rules_dir"] = detection_rules_dir
103
+ if action_on_threat:
104
+ response_cfg["on_threat"] = action_on_threat
105
+ if report_dir:
106
+ response_cfg["report_dir"] = report_dir
107
+
108
+ detection_cfg["fail_closed"] = fail_closed
109
+
110
+ self.scan_tool_descriptions = scan_tool_descriptions
111
+ self.scan_tool_outputs = scan_tool_outputs
112
+
113
+ # Initialize detection engine
114
+ self.detection_engine = DetectionEngine(
115
+ rules_dir=detection_cfg.get("rules_dir"),
116
+ score_threshold=detection_cfg.get("score_threshold"),
117
+ fail_closed=fail_closed,
118
+ )
119
+
120
+ self.reporter = SecurityReporter(
121
+ report_dir=response_cfg.get("report_dir", "./security-reports")
122
+ )
123
+
124
+ self.response_action = ResponseAction(
125
+ default_action=response_cfg.get("on_threat", "block"),
126
+ reporter=self.reporter,
127
+ )
128
+
129
+ logger.info(
130
+ "ProofLayer MCP wrapper initialized with %d rules",
131
+ len(self.detection_engine.rules),
132
+ )
133
+
134
+ @staticmethod
135
+ def _default_config() -> Dict[str, Any]:
136
+ return {
137
+ "detection": {
138
+ "enabled": True,
139
+ "rules_dir": None,
140
+ "fail_closed": True,
141
+ "score_threshold": {
142
+ "allow": (0, 29),
143
+ "warn": (30, 69),
144
+ "block": (70, 100),
145
+ },
146
+ },
147
+ "response": {
148
+ "on_threat": "block",
149
+ "report_dir": "./security-reports",
150
+ "alert_webhook": None,
151
+ },
152
+ }
153
+
154
+ def wrap(self, server: Any) -> Any:
155
+ """
156
+ Wrap an MCP Server instance with ProofLayer security scanning.
157
+
158
+ This hooks into the server's call_tool and list_tools handlers
159
+ by registering wrapped handlers that scan inputs/outputs.
160
+
161
+ Args:
162
+ server: An mcp.server.Server (or mcp.server.fastmcp.FastMCP) instance
163
+
164
+ Returns:
165
+ The same server instance, with security hooks installed
166
+ """
167
+ from mcp.server import Server as MCPServer
168
+
169
+ if not isinstance(server, MCPServer):
170
+ # FastMCP or other wrappers might have a ._mcp_server attribute
171
+ inner = getattr(server, "_mcp_server", None)
172
+ if inner is not None and isinstance(inner, MCPServer):
173
+ self._install_hooks(inner)
174
+ else:
175
+ # Attempt to install hooks directly — duck typing
176
+ self._install_hooks(server)
177
+ else:
178
+ self._install_hooks(server)
179
+
180
+ logger.info("ProofLayer security hooks installed on MCP server")
181
+ return server
182
+
183
+ def _install_hooks(self, server: Any) -> None:
184
+ """Install call_tool and list_tools security hooks on the server."""
185
+ self._wrap_call_tool(server)
186
+ if self.scan_tool_descriptions:
187
+ self._wrap_list_tools(server)
188
+
189
+ def _wrap_call_tool(self, server: Any) -> None:
190
+ """
191
+ Wrap the server's call_tool handler to scan inputs and outputs.
192
+
193
+ The MCP SDK uses @server.call_tool() as a decorator that registers
194
+ a handler. We intercept the registration to wrap the handler.
195
+ """
196
+ original_call_tool_decorator = getattr(server, "call_tool", None)
197
+ if original_call_tool_decorator is None:
198
+ logger.warning(
199
+ "MCP server has no call_tool method; cannot install input scanning"
200
+ )
201
+ return
202
+
203
+ wrapper_self = self
204
+
205
+ def secured_call_tool_decorator():
206
+ """Replacement decorator that wraps the user's handler with security."""
207
+
208
+ def decorator(handler):
209
+ @functools.wraps(handler)
210
+ async def secured_handler(name: str, arguments: Optional[Dict[str, Any]] = None):
211
+ arguments = arguments or {}
212
+
213
+ # --- Input scanning ---
214
+ risk_score, matched_rules = wrapper_self.detection_engine.scan(
215
+ tool_name=name,
216
+ arguments=arguments,
217
+ )
218
+
219
+ action = wrapper_self.response_action.decide_action(risk_score)
220
+
221
+ if action in (ThreatAction.BLOCK, ThreatAction.KILL):
222
+ wrapper_self.reporter.generate_report(
223
+ threat_type="prompt_injection",
224
+ tool_name=name,
225
+ arguments=arguments,
226
+ risk_score=risk_score,
227
+ matched_rules=matched_rules,
228
+ action=action.value,
229
+ )
230
+ logger.error(
231
+ "BLOCKED tool call: %s (score=%d, action=%s, rules=%s)",
232
+ name,
233
+ risk_score,
234
+ action.value,
235
+ [r.id for r in matched_rules],
236
+ )
237
+
238
+ from mcp.types import TextContent, CallToolResult
239
+
240
+ return CallToolResult(
241
+ content=[
242
+ TextContent(
243
+ type="text",
244
+ text=f"Tool call blocked by ProofLayer: {name} "
245
+ f"(risk score: {risk_score})",
246
+ )
247
+ ],
248
+ isError=True,
249
+ )
250
+
251
+ if action == ThreatAction.WARN:
252
+ logger.warning(
253
+ "SUSPICIOUS tool call: %s (score=%d, rules=%s)",
254
+ name,
255
+ risk_score,
256
+ [r.id for r in matched_rules],
257
+ )
258
+
259
+ # --- Execute the original handler ---
260
+ result = await handler(name, arguments)
261
+
262
+ # --- Output scanning ---
263
+ if wrapper_self.scan_tool_outputs and result is not None:
264
+ result = await wrapper_self._scan_tool_output(name, result)
265
+
266
+ return result
267
+
268
+ # Register the secured handler using the original decorator
269
+ return original_call_tool_decorator()(secured_handler)
270
+
271
+ return decorator
272
+
273
+ server.call_tool = secured_call_tool_decorator
274
+
275
+ def _wrap_list_tools(self, server: Any) -> None:
276
+ """
277
+ Wrap the server's list_tools handler to scan tool descriptions
278
+ for prompt injection (tool poisoning attacks).
279
+ """
280
+ original_list_tools_decorator = getattr(server, "list_tools", None)
281
+ if original_list_tools_decorator is None:
282
+ logger.warning(
283
+ "MCP server has no list_tools method; cannot install description scanning"
284
+ )
285
+ return
286
+
287
+ wrapper_self = self
288
+
289
+ def secured_list_tools_decorator():
290
+ """Replacement decorator that scans tool descriptions."""
291
+
292
+ def decorator(handler):
293
+ @functools.wraps(handler)
294
+ async def secured_handler():
295
+ result = await handler()
296
+
297
+ # Scan each tool's description for prompt injection
298
+ if result is not None:
299
+ tools = result if isinstance(result, list) else getattr(result, "tools", [])
300
+ for tool in tools:
301
+ desc = getattr(tool, "description", None) or ""
302
+ if not desc:
303
+ continue
304
+
305
+ score, matched = wrapper_self.detection_engine.scan(
306
+ tool_name=getattr(tool, "name", "unknown"),
307
+ arguments={"description": desc},
308
+ )
309
+
310
+ if score >= 30:
311
+ tool_name = getattr(tool, "name", "unknown")
312
+ logger.warning(
313
+ "TOOL POISONING detected in '%s' description "
314
+ "(score=%d, rules=%s)",
315
+ tool_name,
316
+ score,
317
+ [r.id for r in matched],
318
+ )
319
+ wrapper_self.reporter.generate_report(
320
+ threat_type="tool_poisoning",
321
+ tool_name=tool_name,
322
+ arguments={"description": desc},
323
+ risk_score=score,
324
+ matched_rules=matched,
325
+ action="WARN",
326
+ )
327
+
328
+ return result
329
+
330
+ return original_list_tools_decorator()(secured_handler)
331
+
332
+ return decorator
333
+
334
+ server.list_tools = secured_list_tools_decorator
335
+
336
+ async def _scan_tool_output(self, tool_name: str, result: Any) -> Any:
337
+ """
338
+ Scan tool output for threats before it is returned to the LLM.
339
+
340
+ Checks for data exfiltration payloads, injected instructions, etc.
341
+ in the tool's response content.
342
+ """
343
+ from mcp.types import CallToolResult, TextContent
344
+
345
+ if not isinstance(result, CallToolResult):
346
+ return result
347
+
348
+ for content_item in result.content:
349
+ if not isinstance(content_item, TextContent):
350
+ continue
351
+
352
+ text = content_item.text or ""
353
+ if not text:
354
+ continue
355
+
356
+ score, matched = self.detection_engine.scan(
357
+ tool_name=f"{tool_name}:output",
358
+ arguments={"response_text": text},
359
+ )
360
+
361
+ if score >= 70:
362
+ logger.error(
363
+ "BLOCKED tool output from '%s' (score=%d, rules=%s)",
364
+ tool_name,
365
+ score,
366
+ [r.id for r in matched],
367
+ )
368
+ self.reporter.generate_report(
369
+ threat_type="malicious_output",
370
+ tool_name=tool_name,
371
+ arguments={"response_text": text[:500]},
372
+ risk_score=score,
373
+ matched_rules=matched,
374
+ action="BLOCK",
375
+ )
376
+ return CallToolResult(
377
+ content=[
378
+ TextContent(
379
+ type="text",
380
+ text=f"Tool output blocked by ProofLayer: {tool_name} "
381
+ f"output contained suspicious content (risk score: {score})",
382
+ )
383
+ ],
384
+ isError=True,
385
+ )
386
+
387
+ if score >= 30:
388
+ logger.warning(
389
+ "SUSPICIOUS tool output from '%s' (score=%d, rules=%s)",
390
+ tool_name,
391
+ score,
392
+ [r.id for r in matched],
393
+ )
394
+
395
+ return result
@@ -0,0 +1,86 @@
1
+ """
2
+ ProofLayer Middleware
3
+ =====================
4
+
5
+ Reusable scan-and-decide logic shared between the HTTP transport proxy
6
+ and the MCP SDK wrapper.
7
+ """
8
+
9
+ import logging
10
+ from typing import Optional, Dict, Any, Tuple
11
+
12
+ from ..detection.engine import DetectionEngine
13
+ from ..detection.models import ScanResult
14
+ from ..reporting.reporter import SecurityReporter
15
+ from ..response.actions import ResponseAction, ThreatAction
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ProofLayerMiddleware:
21
+ """
22
+ Encapsulates the core scan → decide → report pipeline.
23
+
24
+ Used by both ProofLayerTransportProxy and ProofLayerMCPWrapper
25
+ to share security scanning logic.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ engine: DetectionEngine,
31
+ reporter: SecurityReporter,
32
+ response_action: ResponseAction,
33
+ ):
34
+ self.engine = engine
35
+ self.reporter = reporter
36
+ self.response_action = response_action
37
+
38
+ def check_tool_call(
39
+ self,
40
+ tool_name: str,
41
+ arguments: Dict[str, Any],
42
+ ) -> Tuple[ScanResult, ThreatAction, Optional[Dict[str, Any]]]:
43
+ """
44
+ Scan a tool call and decide on an action.
45
+
46
+ Args:
47
+ tool_name: MCP tool name.
48
+ arguments: Tool call arguments.
49
+
50
+ Returns:
51
+ (scan_result, action, report_or_None)
52
+ """
53
+ result = self.engine.scan(tool_name=tool_name, arguments=arguments)
54
+ action = self.response_action.decide_action(result.score)
55
+
56
+ report = None
57
+ if action in (ThreatAction.BLOCK, ThreatAction.KILL):
58
+ threat_type = "unknown"
59
+ if result.matched_rules:
60
+ top_rule = max(result.matched_rules, key=lambda r: r.score)
61
+ threat_type = top_rule.category
62
+
63
+ report = self.reporter.generate_report(
64
+ threat_type=threat_type,
65
+ tool_name=tool_name,
66
+ arguments=arguments,
67
+ risk_score=result.score,
68
+ matched_rules=result.matched_rules,
69
+ action=action.value,
70
+ scan_result=result,
71
+ )
72
+
73
+ logger.warning(
74
+ "BLOCKED tool call: %s (score=%d, action=%s, rules=%s)",
75
+ tool_name, result.score, action.value,
76
+ [r.id for r in result.matched_rules],
77
+ )
78
+
79
+ elif action == ThreatAction.WARN:
80
+ logger.warning(
81
+ "SUSPICIOUS tool call: %s (score=%d, rules=%s)",
82
+ tool_name, result.score,
83
+ [r.id for r in result.matched_rules],
84
+ )
85
+
86
+ return result, action, report