spidershield 0.3.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 (65) hide show
  1. spidershield/__init__.py +166 -0
  2. spidershield/__main__.py +5 -0
  3. spidershield/adapters/__init__.py +17 -0
  4. spidershield/adapters/base.py +144 -0
  5. spidershield/adapters/mcp_proxy.py +221 -0
  6. spidershield/adapters/standalone.py +203 -0
  7. spidershield/agent/__init__.py +23 -0
  8. spidershield/agent/allowlist.py +56 -0
  9. spidershield/agent/fixer.py +263 -0
  10. spidershield/agent/issue_codes.py +268 -0
  11. spidershield/agent/models.py +115 -0
  12. spidershield/agent/pinning.py +247 -0
  13. spidershield/agent/report.py +173 -0
  14. spidershield/agent/sarif.py +248 -0
  15. spidershield/agent/scanner.py +408 -0
  16. spidershield/agent/skill_scanner.py +416 -0
  17. spidershield/agent/toxic_flow.py +454 -0
  18. spidershield/audit/__init__.py +6 -0
  19. spidershield/audit/logger.py +108 -0
  20. spidershield/audit/storage.py +143 -0
  21. spidershield/cli.py +1117 -0
  22. spidershield/dataset/__init__.py +1 -0
  23. spidershield/dataset/collector.py +344 -0
  24. spidershield/dataset/db.py +288 -0
  25. spidershield/dlp/__init__.py +27 -0
  26. spidershield/dlp/engine.py +275 -0
  27. spidershield/dlp/pii.py +193 -0
  28. spidershield/dlp/prompt_injection.py +180 -0
  29. spidershield/dlp/secrets.py +227 -0
  30. spidershield/evaluator/__init__.py +0 -0
  31. spidershield/evaluator/runner.py +386 -0
  32. spidershield/guard/__init__.py +22 -0
  33. spidershield/guard/context.py +22 -0
  34. spidershield/guard/core.py +113 -0
  35. spidershield/guard/decision.py +41 -0
  36. spidershield/guard/policy.py +159 -0
  37. spidershield/guard/presets/balanced.yaml +73 -0
  38. spidershield/guard/presets/permissive.yaml +34 -0
  39. spidershield/guard/presets/strict.yaml +84 -0
  40. spidershield/hardener/__init__.py +0 -0
  41. spidershield/hardener/prompt.py +67 -0
  42. spidershield/hardener/quality_gate.py +150 -0
  43. spidershield/hardener/runner.py +343 -0
  44. spidershield/models.py +94 -0
  45. spidershield/rewriter/__init__.py +0 -0
  46. spidershield/rewriter/cache.py +53 -0
  47. spidershield/rewriter/prompt.py +82 -0
  48. spidershield/rewriter/providers.py +104 -0
  49. spidershield/rewriter/quality_gate.py +260 -0
  50. spidershield/rewriter/runner.py +513 -0
  51. spidershield/scanner/__init__.py +0 -0
  52. spidershield/scanner/architecture_check.py +229 -0
  53. spidershield/scanner/description_quality.py +416 -0
  54. spidershield/scanner/license_check.py +92 -0
  55. spidershield/scanner/runner.py +278 -0
  56. spidershield/scanner/security_scan.py +273 -0
  57. spidershield/server.py +173 -0
  58. spidershield/spiderrating.py +573 -0
  59. spidershield/utils/__init__.py +0 -0
  60. spidershield/utils/jsonrpc.py +98 -0
  61. spidershield-0.3.0.dist-info/METADATA +289 -0
  62. spidershield-0.3.0.dist-info/RECORD +65 -0
  63. spidershield-0.3.0.dist-info/WHEEL +4 -0
  64. spidershield-0.3.0.dist-info/entry_points.txt +3 -0
  65. spidershield-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,166 @@
1
+ """SpiderShield -- Scan, improve, certify, and guard MCP servers.
2
+
3
+ Public API:
4
+ from spidershield import SpiderGuard, Decision
5
+
6
+ guard = SpiderGuard(policy="balanced")
7
+ result = guard.check(tool_name="read_file", arguments={"path": "/etc/passwd"})
8
+ # result.decision == Decision.DENY
9
+ # result.reason == "System file access blocked"
10
+ # result.suggestion == "Use application-level files instead"
11
+
12
+ # With audit logging:
13
+ guard = SpiderGuard(policy="strict", audit=True)
14
+
15
+ # MCP proxy shortcut:
16
+ from spidershield import guard_mcp_server
17
+ guard_mcp_server(["npx", "server-filesystem", "/tmp"], policy="balanced")
18
+ """
19
+
20
+ __version__ = "0.3.0"
21
+
22
+ from .guard.context import CallContext
23
+ from .guard.core import RuntimeGuard
24
+ from .guard.decision import Decision, InterceptResult
25
+ from .guard.policy import PolicyEngine, PolicyRule
26
+
27
+
28
+ class SpiderGuard:
29
+ """High-level API for SpiderShield Runtime Guard.
30
+
31
+ Usage:
32
+ guard = SpiderGuard(policy="balanced")
33
+ result = guard.check("read_file", {"path": "/etc/passwd"})
34
+ if result.denied:
35
+ print(result.reason, result.suggestion)
36
+
37
+ With audit logging:
38
+ guard = SpiderGuard(policy="strict", audit=True)
39
+
40
+ With DLP (redact secrets from tool output):
41
+ guard = SpiderGuard(policy="strict", dlp="redact")
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ policy: str = "balanced",
47
+ *,
48
+ audit: bool = False,
49
+ audit_dir: str | None = None,
50
+ dlp: str | None = None,
51
+ ) -> None:
52
+ engine = PolicyEngine.from_name_or_path(policy)
53
+
54
+ logger = None
55
+ if audit:
56
+ from .audit.logger import AuditLogger
57
+ logger = AuditLogger(audit_dir)
58
+
59
+ dlp_engine = None
60
+ if dlp:
61
+ from .dlp.engine import DLPEngine
62
+ dlp_engine = DLPEngine(action=dlp)
63
+
64
+ self._guard = RuntimeGuard(
65
+ policy_engine=engine,
66
+ audit_logger=logger,
67
+ dlp_engine=dlp_engine,
68
+ )
69
+ self._call_index = 0
70
+
71
+ def check(
72
+ self,
73
+ tool_name: str,
74
+ arguments: dict | None = None,
75
+ *,
76
+ session_id: str = "",
77
+ agent_id: str = "",
78
+ ) -> InterceptResult:
79
+ """Check if a tool call is allowed (pre-execution)."""
80
+ ctx = CallContext(
81
+ session_id=session_id or "default",
82
+ agent_id=agent_id or "default",
83
+ tool_name=tool_name,
84
+ arguments=arguments or {},
85
+ call_index=self._call_index,
86
+ )
87
+ self._call_index += 1
88
+ return self._guard.before_call(ctx)
89
+
90
+ def after_check(
91
+ self,
92
+ tool_name: str,
93
+ tool_result: object,
94
+ *,
95
+ session_id: str = "",
96
+ agent_id: str = "",
97
+ call_index: int | None = None,
98
+ ) -> object:
99
+ """Inspect tool output after execution (DLP scan)."""
100
+ ctx = CallContext(
101
+ session_id=session_id or "default",
102
+ agent_id=agent_id or "default",
103
+ tool_name=tool_name,
104
+ arguments={},
105
+ call_index=call_index if call_index is not None else self._call_index,
106
+ )
107
+ return self._guard.after_call(ctx, tool_result)
108
+
109
+ @property
110
+ def guard(self) -> RuntimeGuard:
111
+ """Access the underlying RuntimeGuard for advanced usage."""
112
+ return self._guard
113
+
114
+ @property
115
+ def policy_engine(self) -> PolicyEngine:
116
+ """Access the policy engine for inspection."""
117
+ return self._guard.policy_engine
118
+
119
+
120
+ def guard_mcp_server(
121
+ server_cmd: list[str],
122
+ *,
123
+ policy: str = "balanced",
124
+ verbose: bool = False,
125
+ audit: bool = True,
126
+ audit_dir: str | None = None,
127
+ ) -> int:
128
+ """Start an MCP proxy with security guard around a server.
129
+
130
+ Usage:
131
+ from spidershield import guard_mcp_server
132
+ guard_mcp_server(["npx", "server-filesystem", "/tmp"], policy="balanced")
133
+
134
+ Args:
135
+ server_cmd: Command to start the real MCP server.
136
+ policy: Policy preset (strict/balanced/permissive) or YAML file path.
137
+ verbose: Enable verbose logging to stderr.
138
+ audit: Enable audit logging (default: True).
139
+ audit_dir: Custom audit log directory.
140
+
141
+ Returns:
142
+ Server process return code.
143
+ """
144
+ from .adapters.mcp_proxy import run_mcp_proxy
145
+
146
+ return run_mcp_proxy(
147
+ server_cmd=server_cmd,
148
+ policy=policy,
149
+ verbose=verbose,
150
+ audit_dir=audit_dir,
151
+ no_audit=not audit,
152
+ )
153
+
154
+
155
+ __all__ = [
156
+ # High-level API
157
+ "SpiderGuard",
158
+ "guard_mcp_server",
159
+ # Core types
160
+ "CallContext",
161
+ "Decision",
162
+ "InterceptResult",
163
+ "PolicyEngine",
164
+ "PolicyRule",
165
+ "RuntimeGuard",
166
+ ]
@@ -0,0 +1,5 @@
1
+ """Allow running SpiderShield as ``python -m spidershield``."""
2
+
3
+ from spidershield.cli import main
4
+
5
+ main()
@@ -0,0 +1,17 @@
1
+ """SpiderShield Framework Adapters.
2
+
3
+ Adapters bridge between agent frameworks and the RuntimeGuard core.
4
+ """
5
+
6
+ from .base import AdapterBase, AdapterStats
7
+ from .mcp_proxy import MCPProxyGuard, run_mcp_proxy
8
+ from .standalone import StandaloneGuard, run_standalone_guard
9
+
10
+ __all__ = [
11
+ "AdapterBase",
12
+ "AdapterStats",
13
+ "MCPProxyGuard",
14
+ "StandaloneGuard",
15
+ "run_mcp_proxy",
16
+ "run_standalone_guard",
17
+ ]
@@ -0,0 +1,144 @@
1
+ """AdapterBase — abstract base for SpiderShield framework adapters.
2
+
3
+ All adapters bridge between an agent framework and the RuntimeGuard core.
4
+ Each adapter intercepts tool calls, evaluates them via the guard,
5
+ and returns results (or blocks them).
6
+
7
+ Concrete adapters:
8
+ - MCPProxyGuard (mcp_proxy.py): stdio MCP proxy
9
+ - StandaloneGuard (standalone.py): wraps any subprocess
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import uuid
15
+ from abc import ABC, abstractmethod
16
+ from typing import Any
17
+
18
+ from ..guard.context import CallContext
19
+ from ..guard.core import RuntimeGuard
20
+ from ..guard.decision import Decision, InterceptResult
21
+
22
+
23
+ class AdapterBase(ABC):
24
+ """Abstract base class for SpiderShield adapters.
25
+
26
+ Adapters sit between an agent framework and the RuntimeGuard.
27
+ They intercept tool calls, evaluate them, and forward or block.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ guard: RuntimeGuard,
33
+ *,
34
+ session_id: str = "",
35
+ verbose: bool = False,
36
+ dry_run: bool = False,
37
+ ) -> None:
38
+ self._guard = guard
39
+ self._session_id = session_id or uuid.uuid4().hex[:12]
40
+ self._verbose = verbose
41
+ self._dry_run = dry_run
42
+ self._call_index = 0
43
+ self._stats = AdapterStats()
44
+
45
+ @property
46
+ def guard(self) -> RuntimeGuard:
47
+ return self._guard
48
+
49
+ @property
50
+ def session_id(self) -> str:
51
+ return self._session_id
52
+
53
+ @property
54
+ def stats(self) -> AdapterStats:
55
+ return self._stats
56
+
57
+ @abstractmethod
58
+ def run(self, **kwargs: Any) -> int:
59
+ """Start the adapter. Returns exit code."""
60
+ ...
61
+
62
+ def evaluate_tool_call(
63
+ self, tool_name: str, arguments: dict[str, Any]
64
+ ) -> InterceptResult:
65
+ """Evaluate a tool call against the guard.
66
+
67
+ In dry-run mode, always allows but still logs the decision.
68
+ """
69
+ ctx = CallContext(
70
+ session_id=self._session_id,
71
+ agent_id="adapter",
72
+ tool_name=tool_name,
73
+ arguments=arguments,
74
+ call_index=self._call_index,
75
+ framework=self.framework_name,
76
+ )
77
+ self._call_index += 1
78
+ result = self._guard.before_call(ctx)
79
+
80
+ # Update stats
81
+ self._stats.total_calls += 1
82
+ if result.decision == Decision.ALLOW:
83
+ self._stats.allowed += 1
84
+ elif result.decision == Decision.DENY:
85
+ self._stats.denied += 1
86
+ elif result.decision == Decision.ESCALATE:
87
+ self._stats.escalated += 1
88
+
89
+ # In dry-run mode, log but don't enforce
90
+ if self._dry_run and result.decision == Decision.DENY:
91
+ self._log(f"DRY-RUN DENY (would block): {tool_name} — {result.reason}")
92
+ return InterceptResult(
93
+ decision=Decision.ALLOW,
94
+ reason=f"[dry-run] {result.reason}",
95
+ suggestion=result.suggestion,
96
+ policy_matched=result.policy_matched,
97
+ )
98
+
99
+ return result
100
+
101
+ def evaluate_tool_result(
102
+ self, tool_name: str, tool_result: Any
103
+ ) -> Any:
104
+ """Evaluate tool output (DLP scan)."""
105
+ ctx = CallContext(
106
+ session_id=self._session_id,
107
+ agent_id="adapter",
108
+ tool_name=tool_name,
109
+ arguments={},
110
+ call_index=self._call_index,
111
+ framework=self.framework_name,
112
+ )
113
+ return self._guard.after_call(ctx, tool_result)
114
+
115
+ @property
116
+ def framework_name(self) -> str:
117
+ """Override in subclasses to identify the framework."""
118
+ return "unknown"
119
+
120
+ def _log(self, message: str) -> None:
121
+ """Log to stderr if verbose."""
122
+ if self._verbose:
123
+ import sys
124
+ print(f"[SpiderShield] {message}", file=sys.stderr)
125
+
126
+
127
+ class AdapterStats:
128
+ """Simple counter for adapter-level statistics."""
129
+
130
+ __slots__ = ("total_calls", "allowed", "denied", "escalated")
131
+
132
+ def __init__(self) -> None:
133
+ self.total_calls = 0
134
+ self.allowed = 0
135
+ self.denied = 0
136
+ self.escalated = 0
137
+
138
+ def to_dict(self) -> dict[str, int]:
139
+ return {
140
+ "total_calls": self.total_calls,
141
+ "allowed": self.allowed,
142
+ "denied": self.denied,
143
+ "escalated": self.escalated,
144
+ }
@@ -0,0 +1,221 @@
1
+ """MCP Proxy Adapter — stdio proxy between MCP Client and Server.
2
+
3
+ Sits between Claude Desktop / Cursor and the real MCP server.
4
+ Intercepts tools/call requests and enforces security policies.
5
+
6
+ Architecture:
7
+ MCP Client (Claude Desktop)
8
+ ↓ stdin
9
+ SpiderShield MCP Proxy
10
+ ├─ tools/call → RuntimeGuard.before_call()
11
+ │ ├─ ALLOW → forward to server
12
+ │ ├─ DENY → return error with reason + suggestion
13
+ │ └─ ESCALATE → terminal prompt → allow/deny
14
+ ├─ other messages → passthrough
15
+ ↓ stdout
16
+ MCP Server (real server subprocess)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import subprocess
23
+ import sys
24
+ import threading
25
+ from typing import IO, Any
26
+
27
+ from ..guard.core import RuntimeGuard
28
+ from ..guard.decision import Decision
29
+ from ..guard.policy import PolicyEngine
30
+ from ..utils.jsonrpc import (
31
+ extract_tool_info,
32
+ is_tool_call,
33
+ make_denied_response,
34
+ parse_message,
35
+ serialize_message,
36
+ )
37
+ from .base import AdapterBase
38
+
39
+
40
+ class MCPProxyGuard(AdapterBase):
41
+ """MCP stdio proxy with security guard.
42
+
43
+ Reads JSON-RPC messages from client_in, evaluates tools/call
44
+ against the RuntimeGuard, and forwards allowed calls to the
45
+ real MCP server subprocess.
46
+ """
47
+
48
+ @property
49
+ def framework_name(self) -> str:
50
+ return "mcp"
51
+
52
+ def run(
53
+ self,
54
+ server_cmd: list[str] | None = None,
55
+ client_in: IO[str] | None = None,
56
+ client_out: IO[str] | None = None,
57
+ **kwargs: Any,
58
+ ) -> int:
59
+ """Start proxy: launch server subprocess and relay messages.
60
+
61
+ Args:
62
+ server_cmd: Command to start the real MCP server.
63
+ client_in: Client input stream (default: sys.stdin).
64
+ client_out: Client output stream (default: sys.stdout).
65
+
66
+ Returns:
67
+ Server process return code.
68
+ """
69
+ if not server_cmd:
70
+ raise ValueError("server_cmd is required")
71
+
72
+ client_in = client_in or sys.stdin
73
+ client_out = client_out or sys.stdout
74
+
75
+ # Launch real MCP server as subprocess
76
+ proc = subprocess.Popen(
77
+ server_cmd,
78
+ stdin=subprocess.PIPE,
79
+ stdout=subprocess.PIPE,
80
+ stderr=sys.stderr,
81
+ text=True,
82
+ bufsize=1,
83
+ )
84
+
85
+ try:
86
+ # Thread: relay server stdout → client stdout
87
+ relay_thread = threading.Thread(
88
+ target=self._relay_server_to_client,
89
+ args=(proc.stdout, client_out),
90
+ daemon=True,
91
+ )
92
+ relay_thread.start()
93
+
94
+ # Main thread: relay client stdin → (guard) → server stdin
95
+ self._relay_client_to_server(client_in, proc.stdin, client_out)
96
+
97
+ except (KeyboardInterrupt, BrokenPipeError):
98
+ pass
99
+ finally:
100
+ proc.terminate()
101
+ try:
102
+ proc.wait(timeout=5)
103
+ except subprocess.TimeoutExpired:
104
+ proc.kill()
105
+
106
+ return proc.returncode or 0
107
+
108
+ def _relay_client_to_server(
109
+ self,
110
+ client_in: IO[str],
111
+ server_in: IO[str],
112
+ client_out: IO[str],
113
+ ) -> None:
114
+ """Read from client, evaluate tool calls, forward to server."""
115
+ for line in client_in:
116
+ msg = parse_message(line)
117
+ if msg is None:
118
+ # Non-JSON line — passthrough
119
+ server_in.write(line)
120
+ server_in.flush()
121
+ continue
122
+
123
+ if is_tool_call(msg):
124
+ # Intercept tools/call
125
+ tool_name, arguments = extract_tool_info(msg)
126
+ result = self.evaluate_tool_call(tool_name, arguments)
127
+
128
+ if result.decision == Decision.DENY:
129
+ # Return error to client, don't forward to server
130
+ error_msg = make_denied_response(
131
+ request_id=msg.get("id"),
132
+ reason=result.reason,
133
+ suggestion=result.suggestion,
134
+ policy_matched=result.policy_matched,
135
+ )
136
+ client_out.write(serialize_message(error_msg))
137
+ client_out.flush()
138
+ self._log(f"DENY: {tool_name} — {result.reason}")
139
+ continue
140
+
141
+ if result.decision == Decision.ESCALATE:
142
+ # Terminal prompt for human approval
143
+ if not self._prompt_human(tool_name, arguments, result.reason):
144
+ error_msg = make_denied_response(
145
+ request_id=msg.get("id"),
146
+ reason="Denied by human review",
147
+ suggestion=result.suggestion,
148
+ )
149
+ client_out.write(serialize_message(error_msg))
150
+ client_out.flush()
151
+ self._log(f"ESCALATE→DENY: {tool_name}")
152
+ continue
153
+ self._log(f"ESCALATE→ALLOW: {tool_name}")
154
+
155
+ self._log(f"ALLOW: {tool_name}")
156
+
157
+ # Forward to server (passthrough or allowed tool call)
158
+ server_in.write(line)
159
+ server_in.flush()
160
+
161
+ def _relay_server_to_client(
162
+ self,
163
+ server_out: IO[str],
164
+ client_out: IO[str],
165
+ ) -> None:
166
+ """Relay server responses to client (with DLP scanning)."""
167
+ for line in server_out:
168
+ # DLP scan on server responses
169
+ scanned = self.evaluate_tool_result("server_response", line)
170
+ if isinstance(scanned, str):
171
+ client_out.write(scanned)
172
+ else:
173
+ client_out.write(line)
174
+ client_out.flush()
175
+
176
+ def _prompt_human(
177
+ self, tool_name: str, arguments: dict[str, Any], reason: str
178
+ ) -> bool:
179
+ """Terminal prompt for ESCALATE decisions."""
180
+ print(
181
+ "\n[SpiderShield] Tool call requires approval:",
182
+ file=sys.stderr,
183
+ )
184
+ print(f" Tool: {tool_name}", file=sys.stderr)
185
+ print(f" Args: {json.dumps(arguments, indent=2)}", file=sys.stderr)
186
+ print(f" Reason: {reason}", file=sys.stderr)
187
+ try:
188
+ answer = input(" Allow? [y/N] ").strip().lower()
189
+ return answer in ("y", "yes")
190
+ except (EOFError, KeyboardInterrupt):
191
+ return False
192
+
193
+
194
+ def run_mcp_proxy(
195
+ server_cmd: list[str],
196
+ policy: str = "balanced",
197
+ verbose: bool = False,
198
+ audit_dir: str | None = None,
199
+ no_audit: bool = False,
200
+ dry_run: bool = False,
201
+ ) -> int:
202
+ """Convenience function to run an MCP proxy with security guard.
203
+
204
+ Args:
205
+ server_cmd: Command to start the real MCP server.
206
+ policy: Policy preset name or YAML file path.
207
+ verbose: Enable verbose logging to stderr.
208
+ audit_dir: Custom audit log directory (default: ~/.spidershield/audit/).
209
+ no_audit: Disable audit logging.
210
+ dry_run: Log decisions but don't enforce denials.
211
+
212
+ Returns:
213
+ Server process return code.
214
+ """
215
+ from ..audit.logger import AuditLogger
216
+
217
+ engine = PolicyEngine.from_name_or_path(policy)
218
+ logger = None if no_audit else AuditLogger(audit_dir)
219
+ guard = RuntimeGuard(policy_engine=engine, audit_logger=logger)
220
+ proxy = MCPProxyGuard(guard, verbose=verbose, dry_run=dry_run)
221
+ return proxy.run(server_cmd=server_cmd)