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.
- spidershield/__init__.py +166 -0
- spidershield/__main__.py +5 -0
- spidershield/adapters/__init__.py +17 -0
- spidershield/adapters/base.py +144 -0
- spidershield/adapters/mcp_proxy.py +221 -0
- spidershield/adapters/standalone.py +203 -0
- spidershield/agent/__init__.py +23 -0
- spidershield/agent/allowlist.py +56 -0
- spidershield/agent/fixer.py +263 -0
- spidershield/agent/issue_codes.py +268 -0
- spidershield/agent/models.py +115 -0
- spidershield/agent/pinning.py +247 -0
- spidershield/agent/report.py +173 -0
- spidershield/agent/sarif.py +248 -0
- spidershield/agent/scanner.py +408 -0
- spidershield/agent/skill_scanner.py +416 -0
- spidershield/agent/toxic_flow.py +454 -0
- spidershield/audit/__init__.py +6 -0
- spidershield/audit/logger.py +108 -0
- spidershield/audit/storage.py +143 -0
- spidershield/cli.py +1117 -0
- spidershield/dataset/__init__.py +1 -0
- spidershield/dataset/collector.py +344 -0
- spidershield/dataset/db.py +288 -0
- spidershield/dlp/__init__.py +27 -0
- spidershield/dlp/engine.py +275 -0
- spidershield/dlp/pii.py +193 -0
- spidershield/dlp/prompt_injection.py +180 -0
- spidershield/dlp/secrets.py +227 -0
- spidershield/evaluator/__init__.py +0 -0
- spidershield/evaluator/runner.py +386 -0
- spidershield/guard/__init__.py +22 -0
- spidershield/guard/context.py +22 -0
- spidershield/guard/core.py +113 -0
- spidershield/guard/decision.py +41 -0
- spidershield/guard/policy.py +159 -0
- spidershield/guard/presets/balanced.yaml +73 -0
- spidershield/guard/presets/permissive.yaml +34 -0
- spidershield/guard/presets/strict.yaml +84 -0
- spidershield/hardener/__init__.py +0 -0
- spidershield/hardener/prompt.py +67 -0
- spidershield/hardener/quality_gate.py +150 -0
- spidershield/hardener/runner.py +343 -0
- spidershield/models.py +94 -0
- spidershield/rewriter/__init__.py +0 -0
- spidershield/rewriter/cache.py +53 -0
- spidershield/rewriter/prompt.py +82 -0
- spidershield/rewriter/providers.py +104 -0
- spidershield/rewriter/quality_gate.py +260 -0
- spidershield/rewriter/runner.py +513 -0
- spidershield/scanner/__init__.py +0 -0
- spidershield/scanner/architecture_check.py +229 -0
- spidershield/scanner/description_quality.py +416 -0
- spidershield/scanner/license_check.py +92 -0
- spidershield/scanner/runner.py +278 -0
- spidershield/scanner/security_scan.py +273 -0
- spidershield/server.py +173 -0
- spidershield/spiderrating.py +573 -0
- spidershield/utils/__init__.py +0 -0
- spidershield/utils/jsonrpc.py +98 -0
- spidershield-0.3.0.dist-info/METADATA +289 -0
- spidershield-0.3.0.dist-info/RECORD +65 -0
- spidershield-0.3.0.dist-info/WHEEL +4 -0
- spidershield-0.3.0.dist-info/entry_points.txt +3 -0
- spidershield-0.3.0.dist-info/licenses/LICENSE +21 -0
spidershield/__init__.py
ADDED
|
@@ -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
|
+
]
|
spidershield/__main__.py
ADDED
|
@@ -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)
|