mcpshield-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.
@@ -0,0 +1,279 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcpshield-runtime
3
+ Version: 0.1.0
4
+ Summary: Secure MCP runtime — policy enforcement, SSRF blocking, audit logging
5
+ Author: Sri Sowmya Nemani
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/srisowmya2000/mcp-shield
8
+ Project-URL: Repository, https://github.com/srisowmya2000/mcp-shield
9
+ Keywords: mcp,security,ssrf,llm,agent,policy,sandbox
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Topic :: Security
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Requires-Python: >=3.12
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: fastapi>=0.115
17
+ Requires-Dist: uvicorn>=0.30
18
+ Requires-Dist: pydantic>=2.0
19
+ Requires-Dist: pydantic-settings>=2.0
20
+ Requires-Dist: typer>=0.12
21
+ Requires-Dist: mcp>=1.0
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: rich>=13.0
25
+
26
+ # mcp-shield 🛡️
27
+
28
+ > **The security runtime for MCP servers.**
29
+ > Every tool call inspected. Every attack blocked. Every decision logged.
30
+
31
+ ![Python](https://img.shields.io/badge/python-3.12-blue?style=flat-square)
32
+ ![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)
33
+ ![Tests](https://img.shields.io/badge/tests-12%20passing-brightgreen?style=flat-square)
34
+ ![Status](https://img.shields.io/badge/status-active-success?style=flat-square)
35
+
36
+ ---
37
+
38
+ ## What is MCP?
39
+
40
+ **Model Context Protocol (MCP)** is an open standard that lets AI assistants (like Claude, Cursor, Copilot) connect to external tools and services — file systems, APIs, databases, browsers — through **MCP servers**.
41
+
42
+ Think of MCP servers as plugins that give AI agents real-world capabilities.
43
+
44
+ ---
45
+
46
+ ## The Problem
47
+
48
+ MCP servers run as **trusted processes** on your machine with access to:
49
+
50
+ | Access | Risk |
51
+ |---|---|
52
+ | 🗂️ Your filesystem | Read `/etc/passwd`, steal SSH keys |
53
+ | 🌐 Your network | SSRF to `169.254.169.254` (AWS metadata) |
54
+ | 🔑 Your environment variables | Steal API keys, tokens, secrets |
55
+ | ⚙️ Shell execution | Run arbitrary commands |
56
+
57
+ **A malicious or compromised MCP server can silently exfiltrate your secrets, pivot to internal infrastructure, or execute code — and you'd never know.**
58
+
59
+ This is not theoretical. A real SSRF vulnerability was found in an MCP OAuth HTTP transport implementation that allowed exactly this class of attack.
60
+
61
+ ---
62
+
63
+ ## How mcp-shield Fixes This
64
+
65
+ mcp-shield sits **between your AI agent and the MCP server** as a policy enforcement layer.
66
+ Before any tool executes, mcp-shield evaluates it. If it's not allowed — it's blocked.
67
+
68
+ ```
69
+ AI Agent
70
+
71
+
72
+ mcp-shield /inspect
73
+
74
+ ├── Tool allowlist check → "read_secrets" not allowed → 🚫 BLOCK
75
+ ├── Blocked pattern check → "ssrf_fetch" is dangerous → 🚫 BLOCK
76
+ ├── Argument scanning → "169.254.169.254" in args → 🚫 BLOCK
77
+
78
+ └── Passed all checks → ✅ ALLOW → MCP Server executes
79
+
80
+
81
+ Audit Log (SQLite)
82
+ ```
83
+
84
+ Every decision — ALLOW or BLOCK — is logged with a full audit trail.
85
+
86
+ ---
87
+
88
+ ## Live Demo
89
+
90
+ ```bash
91
+ # Start mcp-shield
92
+ uvicorn runtime.api.main:app --reload
93
+
94
+ # 🚫 Attempt secret theft → BLOCKED
95
+ curl -X POST http://localhost:8000/inspect \
96
+ -H "Content-Type: application/json" \
97
+ -d '{"server_name":"evil","policy":"default","tool_call":{"tool_name":"read_secrets","arguments":{}}}'
98
+
99
+ # → {"decision":"BLOCK","reason":"Tool 'read_secrets' is not in the allowed_tools list","blocked":true}
100
+
101
+ # 🚫 Attempt SSRF to AWS metadata endpoint → BLOCKED
102
+ curl -X POST http://localhost:8000/inspect \
103
+ -H "Content-Type: application/json" \
104
+ -d '{"server_name":"evil","policy":"default","tool_call":{"tool_name":"ssrf_fetch","arguments":{"url":"http://169.254.169.254/latest/meta-data/"}}}'
105
+
106
+ # → {"decision":"BLOCK","reason":"Argument contains blocked pattern: '169.254.169.254'","blocked":true}
107
+
108
+ # ✅ Safe tool → ALLOWED
109
+ curl -X POST http://localhost:8000/inspect \
110
+ -H "Content-Type: application/json" \
111
+ -d '{"server_name":"safe","policy":"default","tool_call":{"tool_name":"safe_tool","arguments":{"name":"Sri"}}}'
112
+
113
+ # → {"decision":"ALLOW","reason":"Passed all policy checks","blocked":false}
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Key Features
119
+
120
+ | Feature | Description |
121
+ |---|---|
122
+ | 🔒 **Policy Engine** | YAML-based allowlists + blocked patterns, per-server policies |
123
+ | 🔍 **Argument Scanning** | Recursively scans nested args for SSRF, path traversal, dangerous patterns |
124
+ | 📋 **Audit Logger** | Every decision logged to SQLite with timestamp, server, tool, reason |
125
+ | 🐳 **Docker Sandbox** | Hardened containers: `--cap-drop=ALL`, `--network=none`, `--read-only` |
126
+ | 📊 **Risk Scorer** | Scores MCP servers LOW / MEDIUM / HIGH based on tool capabilities |
127
+ | 🖥️ **Live Dashboard** | Real-time web UI showing live block/allow feed at `/dashboard` |
128
+ | ⚡ **CLI** | `mcpshield inspect`, `mcpshield audit`, `mcpshield stats`, `mcpshield risk` |
129
+
130
+ ---
131
+
132
+ ## Policies
133
+
134
+ Policies are simple YAML files. Drop one in `policies/` and reference it by name.
135
+
136
+ ```yaml
137
+ # policies/default.yaml
138
+ allowed_tools:
139
+ - safe_tool
140
+ - list_files
141
+ - get_time
142
+
143
+ block_network: true
144
+ block_env_access: true
145
+
146
+ blocked_arg_patterns:
147
+ - "169.254.169.254" # AWS metadata SSRF
148
+ - "169.254.170.2" # ECS metadata SSRF
149
+ - "localhost"
150
+ - "127.0.0.1"
151
+ - "/etc/passwd"
152
+ - "/etc/shadow"
153
+ - "file://"
154
+ - "gopher://"
155
+
156
+ max_memory_mb: 256
157
+ execution_timeout_seconds: 30
158
+ ```
159
+
160
+ Switch policy per server:
161
+ ```bash
162
+ POST /inspect → { "policy": "strict", ... }
163
+ ```
164
+
165
+ ---
166
+
167
+ ## Architecture
168
+
169
+ ```
170
+ mcp-shield/
171
+ ├── runtime/
172
+ │ ├── api/
173
+ │ │ └── main.py # FastAPI — /inspect /audit /sandbox /dashboard
174
+ │ ├── policy_engine.py # YAML policy loader + evaluator
175
+ │ ├── audit_logger.py # SQLite decision log
176
+ │ ├── risk_scorer.py # LOW/MEDIUM/HIGH risk scoring
177
+ │ ├── cli.py # Typer CLI — inspect/audit/stats/risk
178
+ │ ├── models.py # Pydantic schemas
179
+ │ └── sandbox/
180
+ │ ├── base.py # Abstract backend interface
181
+ │ └── docker_backend.py # Hardened Docker sandbox
182
+ ├── policies/
183
+ │ ├── default.yaml # Standard policy
184
+ │ └── strict.yaml # Zero-trust policy
185
+ ├── examples/
186
+ │ ├── malicious_mcp_server/ # Demo attacker (SSRF + secret theft + exec)
187
+ │ └── safe_mcp_server/ # Demo benign server
188
+ └── tests/ # 12 tests — all passing
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Quickstart
194
+
195
+ ```bash
196
+ # 1. Clone
197
+ git clone https://github.com/srisowmya2000/mcp-shield
198
+ cd mcp-shield
199
+
200
+ # 2. Install
201
+ python3 -m venv .venv && source .venv/bin/activate
202
+ pip install fastapi uvicorn pydantic pydantic-settings mcp httpx pyyaml
203
+
204
+ # 3. Start
205
+ uvicorn runtime.api.main:app --reload
206
+
207
+ # 4. Open
208
+ # API docs → http://localhost:8000/docs
209
+ # Dashboard → http://localhost:8000/dashboard
210
+ ```
211
+
212
+ ---
213
+
214
+ ## CLI
215
+
216
+ ```bash
217
+ # Inspect a tool call
218
+ python3 -m runtime.cli inspect read_secrets
219
+ # → 🚫 BLOCKED — Tool 'read_secrets' is not in the allowed_tools list
220
+
221
+ # Score a server's risk
222
+ python3 -m runtime.cli risk "read_secrets,ssrf_fetch,safe_tool"
223
+ # → 🔴 HIGH RISK (score: 80)
224
+
225
+ # View audit log
226
+ python3 -m runtime.cli audit
227
+
228
+ # View stats
229
+ python3 -m runtime.cli stats
230
+ # → Total: 6 | Allowed: 2 | Blocked: 4 (67% block rate)
231
+ ```
232
+
233
+ ---
234
+
235
+ ## API Reference
236
+
237
+ | Endpoint | Method | Description |
238
+ |---|---|---|
239
+ | `/health` | GET | Service health check |
240
+ | `/inspect` | POST | Evaluate tool call → ALLOW / BLOCK |
241
+ | `/audit` | GET | Recent audit log entries |
242
+ | `/audit/stats` | GET | Total / allowed / blocked counts |
243
+ | `/risk/score` | POST | Score server risk by tool list |
244
+ | `/sandbox/launch` | POST | Launch MCP server in hardened Docker |
245
+ | `/sandbox/stop/{name}` | POST | Stop a running sandbox |
246
+ | `/sandbox/list` | GET | List running sandboxes |
247
+ | `/dashboard` | GET | Live real-time dashboard |
248
+
249
+ ---
250
+
251
+ ## Tests
252
+
253
+ ```bash
254
+ pip install pytest
255
+ pytest tests/ -v
256
+ # 12 passed in 0.11s
257
+ ```
258
+
259
+ Covers: tool allowlist blocking, SSRF argument detection, nested arg scanning, strict policy, edge cases.
260
+
261
+ ---
262
+
263
+ ## Roadmap
264
+
265
+ - [x] Policy engine (allowlist + pattern scanning)
266
+ - [x] Audit logger (SQLite)
267
+ - [x] FastAPI REST surface
268
+ - [x] Docker sandbox backend (hardened)
269
+ - [x] Demo malicious MCP server
270
+ - [x] Risk scorer (LOW / MEDIUM / HIGH)
271
+ - [x] CLI (`mcpshield inspect`, `audit`, `stats`, `risk`)
272
+ - [x] Real-time dashboard
273
+ - [ ] Firecracker microVM backend
274
+ - [ ] PyPI package (`pip install mcp-shield`)
275
+ - [ ] `threat-model.md`
276
+
277
+ ---
278
+
279
+
@@ -0,0 +1,17 @@
1
+ runtime/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ runtime/audit_logger.py,sha256=HVS3ofU_2VbplfnR_68V6hdgl78wrZqJwHDZbf8QFCQ,2697
3
+ runtime/cli.py,sha256=cQHwYxpmDW_Q3tk7u-GZx_3liXa1mjBO6EjwM5FdoSc,5253
4
+ runtime/models.py,sha256=TU75MNrxnfwLt0oYlP_JL2a-u0goR0YC1c2NB2sNjOk,564
5
+ runtime/policy_engine.py,sha256=xid5ENKpMfRC0sCfupcN7y21s8iuK_3XUWzRzgEHFrA,2066
6
+ runtime/risk_scorer.py,sha256=gTWBomzfd83H6j5rbKo_DRuh2vrYuv55NPjowQ3_fmM,996
7
+ runtime/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ runtime/api/main.py,sha256=wke6AuBC9LekwTgUX4330Nj4OTUhsM8uNZ6olVCdWug,2374
9
+ runtime/sandbox/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ runtime/sandbox/base.py,sha256=w4qmgAgVyPbrXkZNO3E_fYKZHQSe-49x3-kgXqtNyU0,363
11
+ runtime/sandbox/docker_backend.py,sha256=dvTzHyPSyPQawaxBCxPws04UTYHPQdJEDh-HRttQ4GE,1865
12
+ runtime/static/dashboard.html,sha256=qfoWAfvnh2L-WzVMGINYG2N01gaMrG2G0hbE3KDXj_g,6650
13
+ mcpshield_runtime-0.1.0.dist-info/METADATA,sha256=qxng4-FrDFb7KDrXTWyq1badI6mmrQvC7HsMIXaCYb8,8700
14
+ mcpshield_runtime-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ mcpshield_runtime-0.1.0.dist-info/entry_points.txt,sha256=mVjnVLyxraKjVbjTS-76kV_1rQwYp2Bm5TFYGqFbqKw,46
16
+ mcpshield_runtime-0.1.0.dist-info/top_level.txt,sha256=-unY84bWeVaGfe3vfIlHYZmkol7p_-E1YKa_rnrmxAc,8
17
+ mcpshield_runtime-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcpshield = runtime.cli:app
@@ -0,0 +1 @@
1
+ runtime
runtime/__init__.py ADDED
File without changes
File without changes
runtime/api/main.py ADDED
@@ -0,0 +1,73 @@
1
+ from fastapi import FastAPI, HTTPException
2
+ from pydantic import BaseModel
3
+ from runtime.models import ToolCall, RunConfig, PolicyDecision
4
+ from runtime.policy_engine import evaluate
5
+ from runtime.audit_logger import log_decision, get_recent_logs, get_stats
6
+ from runtime.sandbox.docker_backend import launch_sandbox, stop_sandbox, list_running_sandboxes
7
+
8
+ app = FastAPI(title="mcp-shield", description="Secure MCP runtime with policy enforcement", version="0.1.0")
9
+
10
+ class InspectRequest(BaseModel):
11
+ server_name: str
12
+ policy: str = "default"
13
+ tool_call: ToolCall
14
+
15
+ class LaunchRequest(BaseModel):
16
+ server_name: str
17
+ image: str
18
+ policy: str = "default"
19
+ env_vars: dict[str, str] = {}
20
+
21
+ @app.get("/health")
22
+ def health():
23
+ return {"status": "ok", "service": "mcp-shield", "version": "0.1.0"}
24
+
25
+ @app.post("/inspect")
26
+ def inspect_tool_call(req: InspectRequest):
27
+ try:
28
+ result = evaluate(req.tool_call, req.policy)
29
+ except FileNotFoundError as e:
30
+ raise HTTPException(status_code=404, detail=str(e))
31
+ log_decision(req.server_name, req.tool_call, result)
32
+ return {"server": req.server_name, "tool": req.tool_call.tool_name,
33
+ "policy": req.policy, "decision": result.decision,
34
+ "reason": result.reason, "blocked": result.decision == PolicyDecision.BLOCK}
35
+
36
+ @app.get("/audit")
37
+ def audit_log(limit: int = 50):
38
+ return {"logs": get_recent_logs(limit)}
39
+
40
+ @app.get("/audit/stats")
41
+ def audit_stats():
42
+ return get_stats()
43
+
44
+ @app.post("/sandbox/launch")
45
+ def launch(req: LaunchRequest):
46
+ config = RunConfig(server_name=req.server_name, image=req.image,
47
+ policy=req.policy, env_vars=req.env_vars)
48
+ return launch_sandbox(config)
49
+
50
+ @app.post("/sandbox/stop/{server_name}")
51
+ def stop(server_name: str):
52
+ return stop_sandbox(server_name)
53
+
54
+ @app.get("/sandbox/list")
55
+ def list_sandboxes():
56
+ return {"sandboxes": list_running_sandboxes()}
57
+
58
+ from runtime.risk_scorer import score_server
59
+
60
+ class RiskRequest(BaseModel):
61
+ tool_names: list[str]
62
+
63
+ @app.post("/risk/score")
64
+ def risk_score(req: RiskRequest):
65
+ return score_server(req.tool_names)
66
+
67
+ from fastapi.responses import FileResponse
68
+ from pathlib import Path
69
+
70
+ @app.get("/dashboard", tags=["dashboard"])
71
+ def dashboard():
72
+ """Live real-time decision dashboard."""
73
+ return FileResponse(Path(__file__).parent.parent / "static" / "dashboard.html")
@@ -0,0 +1,67 @@
1
+ import sqlite3
2
+ import json
3
+ import logging
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from runtime.models import ToolCall, PolicyResult, PolicyDecision
7
+
8
+ DB_PATH = Path(__file__).parent.parent / "reports" / "audit.db"
9
+
10
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%S")
11
+ logger = logging.getLogger("mcp-shield")
12
+
13
+ def init_db():
14
+ DB_PATH.parent.mkdir(exist_ok=True)
15
+ conn = sqlite3.connect(DB_PATH)
16
+ conn.execute("""
17
+ CREATE TABLE IF NOT EXISTS audit_log (
18
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
19
+ timestamp TEXT NOT NULL,
20
+ server_name TEXT NOT NULL,
21
+ tool_name TEXT NOT NULL,
22
+ arguments TEXT,
23
+ decision TEXT NOT NULL,
24
+ reason TEXT NOT NULL
25
+ )
26
+ """)
27
+ conn.commit()
28
+ conn.close()
29
+
30
+ def log_decision(server_name: str, tool_call: ToolCall, result: PolicyResult) -> None:
31
+ init_db()
32
+ timestamp = datetime.now(timezone.utc).isoformat()
33
+ icon = "✅" if result.decision == PolicyDecision.ALLOW else "🚫"
34
+ entry = {"timestamp": timestamp, "server": server_name, "tool": tool_call.tool_name,
35
+ "decision": result.decision.value, "reason": result.reason}
36
+ logger.info(f"{icon} {json.dumps(entry)}")
37
+ conn = sqlite3.connect(DB_PATH)
38
+ conn.execute("""
39
+ INSERT INTO audit_log (timestamp, server_name, tool_name, arguments, decision, reason)
40
+ VALUES (?, ?, ?, ?, ?, ?)
41
+ """, (timestamp, server_name, tool_call.tool_name, json.dumps(tool_call.arguments),
42
+ result.decision.value, result.reason))
43
+ conn.commit()
44
+ conn.close()
45
+
46
+ def get_recent_logs(limit: int = 50) -> list[dict]:
47
+ if not DB_PATH.exists():
48
+ return []
49
+ conn = sqlite3.connect(DB_PATH)
50
+ rows = conn.execute("""
51
+ SELECT timestamp, server_name, tool_name, arguments, decision, reason
52
+ FROM audit_log ORDER BY id DESC LIMIT ?
53
+ """, (limit,)).fetchall()
54
+ conn.close()
55
+ return [{"timestamp": r[0], "server": r[1], "tool": r[2],
56
+ "arguments": json.loads(r[3]) if r[3] else {}, "decision": r[4], "reason": r[5]}
57
+ for r in rows]
58
+
59
+ def get_stats() -> dict:
60
+ if not DB_PATH.exists():
61
+ return {"total": 0, "allowed": 0, "blocked": 0}
62
+ conn = sqlite3.connect(DB_PATH)
63
+ total = conn.execute("SELECT COUNT(*) FROM audit_log").fetchone()[0]
64
+ allowed = conn.execute("SELECT COUNT(*) FROM audit_log WHERE decision='ALLOW'").fetchone()[0]
65
+ blocked = conn.execute("SELECT COUNT(*) FROM audit_log WHERE decision='BLOCK'").fetchone()[0]
66
+ conn.close()
67
+ return {"total": total, "allowed": allowed, "blocked": blocked}
runtime/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ import typer
2
+ import httpx
3
+ import json
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+ from rich.panel import Panel
7
+ from rich import box
8
+
9
+ app = typer.Typer(
10
+ name="mcpshield",
11
+ help="🛡️ mcp-shield — secure MCP runtime CLI",
12
+ add_completion=False
13
+ )
14
+ console = Console()
15
+ BASE = "http://localhost:8000"
16
+
17
+
18
+ @app.command()
19
+ def inspect(
20
+ tool: str = typer.Argument(..., help="Tool name to inspect"),
21
+ policy: str = typer.Option("default", "--policy", "-p", help="Policy to evaluate against"),
22
+ server: str = typer.Option("cli", "--server", "-s", help="Server name label"),
23
+ args: str = typer.Option("{}", "--args", "-a", help='Tool arguments as JSON string'),
24
+ ):
25
+ """Inspect a tool call against a policy. Returns ALLOW or BLOCK."""
26
+ try:
27
+ arguments = json.loads(args)
28
+ except json.JSONDecodeError:
29
+ console.print("[red]Error: --args must be valid JSON[/red]")
30
+ raise typer.Exit(1)
31
+
32
+ try:
33
+ r = httpx.post(f"{BASE}/inspect", json={
34
+ "server_name": server,
35
+ "policy": policy,
36
+ "tool_call": {"tool_name": tool, "arguments": arguments}
37
+ })
38
+ data = r.json()
39
+ if data["blocked"]:
40
+ console.print(Panel(
41
+ f"[bold red]🚫 BLOCKED[/bold red]\n\n"
42
+ f"Tool: [yellow]{tool}[/yellow]\n"
43
+ f"Policy: {policy}\n"
44
+ f"Reason: {data['reason']}",
45
+ title="mcp-shield decision", border_style="red"
46
+ ))
47
+ else:
48
+ console.print(Panel(
49
+ f"[bold green]✅ ALLOWED[/bold green]\n\n"
50
+ f"Tool: [yellow]{tool}[/yellow]\n"
51
+ f"Policy: {policy}\n"
52
+ f"Reason: {data['reason']}",
53
+ title="mcp-shield decision", border_style="green"
54
+ ))
55
+ except httpx.ConnectError:
56
+ console.print("[red]Error: Cannot connect to mcp-shield API. Is it running?[/red]")
57
+ console.print("[dim]Run: uvicorn runtime.api.main:app --reload[/dim]")
58
+ raise typer.Exit(1)
59
+
60
+
61
+ @app.command()
62
+ def audit(
63
+ limit: int = typer.Option(20, "--limit", "-n", help="Number of recent entries to show"),
64
+ ):
65
+ """Show recent audit log entries."""
66
+ try:
67
+ r = httpx.get(f"{BASE}/audit?limit={limit}")
68
+ logs = r.json()["logs"]
69
+
70
+ if not logs:
71
+ console.print("[dim]No audit entries yet.[/dim]")
72
+ return
73
+
74
+ table = Table(box=box.ROUNDED, show_header=True, header_style="bold cyan")
75
+ table.add_column("Time", style="dim", width=20)
76
+ table.add_column("Server", width=16)
77
+ table.add_column("Tool", width=18)
78
+ table.add_column("Decision", width=10)
79
+ table.add_column("Reason")
80
+
81
+ for log in logs:
82
+ decision_str = (
83
+ "[bold red]🚫 BLOCK[/bold red]"
84
+ if log["decision"] == "BLOCK"
85
+ else "[bold green]✅ ALLOW[/bold green]"
86
+ )
87
+ table.add_row(
88
+ log["timestamp"][:19].replace("T", " "),
89
+ log["server"],
90
+ log["tool"],
91
+ decision_str,
92
+ log["reason"][:55] + ("…" if len(log["reason"]) > 55 else "")
93
+ )
94
+
95
+ console.print(table)
96
+ except httpx.ConnectError:
97
+ console.print("[red]Error: Cannot connect to mcp-shield API.[/red]")
98
+ raise typer.Exit(1)
99
+
100
+
101
+ @app.command()
102
+ def stats():
103
+ """Show audit statistics — total, allowed, blocked."""
104
+ try:
105
+ r = httpx.get(f"{BASE}/audit/stats")
106
+ data = r.json()
107
+ total = data["total"]
108
+ allowed = data["allowed"]
109
+ blocked = data["blocked"]
110
+ pct = f"{(blocked/total*100):.0f}%" if total > 0 else "0%"
111
+
112
+ console.print(Panel(
113
+ f"[bold]Total decisions:[/bold] {total}\n"
114
+ f"[green]✅ Allowed:[/green] {allowed}\n"
115
+ f"[red]🚫 Blocked:[/red] {blocked} ({pct} block rate)",
116
+ title="🛡️ mcp-shield audit stats",
117
+ border_style="cyan"
118
+ ))
119
+ except httpx.ConnectError:
120
+ console.print("[red]Error: Cannot connect to mcp-shield API.[/red]")
121
+ raise typer.Exit(1)
122
+
123
+
124
+ @app.command()
125
+ def risk(
126
+ tools: str = typer.Argument(..., help="Comma-separated list of tool names to score"),
127
+ ):
128
+ """Score an MCP server's risk level based on its tools."""
129
+ tool_list = [t.strip() for t in tools.split(",")]
130
+ try:
131
+ r = httpx.post(f"{BASE}/risk/score", json={"tool_names": tool_list})
132
+ data = r.json()
133
+
134
+ color = {"HIGH": "red", "MEDIUM": "yellow", "LOW": "green"}[data["risk_level"]]
135
+ console.print(Panel(
136
+ f"[bold {color}]{data['risk_level']} RISK (score: {data['risk_score']})[/bold {color}]\n\n"
137
+ f"[red]High-risk tools:[/red] {data['high_risk_tools'] or 'none'}\n"
138
+ f"[yellow]Medium-risk tools:[/yellow] {data['medium_risk_tools'] or 'none'}\n\n"
139
+ f"[dim]{data['recommendation']}[/dim]",
140
+ title="🛡️ mcp-shield risk score",
141
+ border_style=color
142
+ ))
143
+ except httpx.ConnectError:
144
+ console.print("[red]Error: Cannot connect to mcp-shield API.[/red]")
145
+ raise typer.Exit(1)
146
+
147
+
148
+ if __name__ == "__main__":
149
+ app()
runtime/models.py ADDED
@@ -0,0 +1,27 @@
1
+ from pydantic import BaseModel
2
+ from typing import Any, Optional
3
+ from enum import Enum
4
+
5
+ class PolicyDecision(str, Enum):
6
+ ALLOW = "ALLOW"
7
+ BLOCK = "BLOCK"
8
+
9
+ class ToolCall(BaseModel):
10
+ tool_name: str
11
+ arguments: dict[str, Any] = {}
12
+
13
+ class PolicyResult(BaseModel):
14
+ decision: PolicyDecision
15
+ reason: str
16
+
17
+ class RunConfig(BaseModel):
18
+ server_name: str
19
+ image: str
20
+ policy: str = "default"
21
+ env_vars: dict[str, str] = {}
22
+
23
+ class SandboxStatus(BaseModel):
24
+ container_id: Optional[str]
25
+ server_name: str
26
+ status: str
27
+ policy: str
@@ -0,0 +1,56 @@
1
+ import yaml
2
+ from pathlib import Path
3
+ from runtime.models import ToolCall, PolicyResult, PolicyDecision
4
+
5
+ POLICIES_DIR = Path(__file__).parent.parent / "policies"
6
+
7
+ def load_policy(name: str) -> dict:
8
+ path = POLICIES_DIR / f"{name}.yaml"
9
+ if not path.exists():
10
+ raise FileNotFoundError(f"Policy file not found: {name}.yaml")
11
+ with open(path) as f:
12
+ return yaml.safe_load(f)
13
+
14
+ def evaluate(tool_call: ToolCall, policy_name: str = "default") -> PolicyResult:
15
+ policy = load_policy(policy_name)
16
+ allowed_tools = policy.get("allowed_tools", [])
17
+ blocked_patterns = policy.get("blocked_tool_patterns", [])
18
+ blocked_arg_patterns = policy.get("blocked_arg_patterns", [])
19
+
20
+ if tool_call.tool_name not in allowed_tools:
21
+ return PolicyResult(
22
+ decision=PolicyDecision.BLOCK,
23
+ reason=f"Tool '{tool_call.tool_name}' is not in the allowed_tools list"
24
+ )
25
+
26
+ for pattern in blocked_patterns:
27
+ if pattern.lower() in tool_call.tool_name.lower():
28
+ return PolicyResult(
29
+ decision=PolicyDecision.BLOCK,
30
+ reason=f"Tool name matches blocked pattern: '{pattern}'"
31
+ )
32
+
33
+ all_values = _flatten_args(tool_call.arguments)
34
+ for val in all_values:
35
+ for pattern in blocked_arg_patterns:
36
+ if pattern.lower() in val.lower():
37
+ return PolicyResult(
38
+ decision=PolicyDecision.BLOCK,
39
+ reason=f"Argument contains blocked pattern: '{pattern}' — possible SSRF or path traversal"
40
+ )
41
+
42
+ return PolicyResult(decision=PolicyDecision.ALLOW, reason="Passed all policy checks")
43
+
44
+ def _flatten_args(args: dict, depth: int = 0) -> list[str]:
45
+ if depth > 5:
46
+ return []
47
+ result = []
48
+ for val in args.values():
49
+ if isinstance(val, dict):
50
+ result.extend(_flatten_args(val, depth + 1))
51
+ elif isinstance(val, list):
52
+ for item in val:
53
+ result.append(str(item))
54
+ else:
55
+ result.append(str(val))
56
+ return result
runtime/risk_scorer.py ADDED
@@ -0,0 +1,30 @@
1
+ from runtime.models import PolicyDecision
2
+
3
+ HIGH_RISK_TOOLS = {"read_secrets", "ssrf_fetch", "exec_command", "shell_exec", "delete_file", "write_file"}
4
+ MEDIUM_RISK_TOOLS = {"read_file", "list_files", "network_request", "http_get"}
5
+
6
+ def score_server(tool_names: list[str]) -> dict:
7
+ high = [t for t in tool_names if t in HIGH_RISK_TOOLS]
8
+ medium = [t for t in tool_names if t in MEDIUM_RISK_TOOLS]
9
+
10
+ if high:
11
+ level = "HIGH"
12
+ score = 90 - (10 * max(0, 3 - len(high)))
13
+ elif medium:
14
+ level = "MEDIUM"
15
+ score = 50
16
+ else:
17
+ level = "LOW"
18
+ score = 10
19
+
20
+ return {
21
+ "risk_level": level,
22
+ "risk_score": score,
23
+ "high_risk_tools": high,
24
+ "medium_risk_tools": medium,
25
+ "recommendation": {
26
+ "HIGH": "Do not run without strict policy. Use isolated network.",
27
+ "MEDIUM": "Run with default policy. Monitor closely.",
28
+ "LOW": "Safe to run with standard policy.",
29
+ }[level]
30
+ }
File without changes
@@ -0,0 +1,15 @@
1
+ from abc import ABC, abstractmethod
2
+ from runtime.models import RunConfig, SandboxStatus
3
+
4
+ class SandboxBackend(ABC):
5
+ @abstractmethod
6
+ def launch(self, config: RunConfig) -> SandboxStatus:
7
+ pass
8
+
9
+ @abstractmethod
10
+ def stop(self, server_name: str) -> dict:
11
+ pass
12
+
13
+ @abstractmethod
14
+ def list_running(self) -> list[dict]:
15
+ pass
@@ -0,0 +1,43 @@
1
+ import subprocess
2
+ import json
3
+ from runtime.models import RunConfig, SandboxStatus
4
+
5
+ def launch_sandbox(config: RunConfig) -> SandboxStatus:
6
+ cmd = [
7
+ "docker", "run", "--rm", "--detach",
8
+ "--name", f"mcp-shield-{config.server_name}",
9
+ "--memory=256m", "--cpus=0.5", "--pids-limit=64",
10
+ "--cap-drop=ALL", "--no-new-privileges", "--read-only",
11
+ "--network=none",
12
+ "--tmpfs=/tmp:rw,noexec,nosuid,size=64m",
13
+ ]
14
+ for key, val in config.env_vars.items():
15
+ cmd += ["--env", f"{key}={val}"]
16
+ cmd.append(config.image)
17
+ try:
18
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
19
+ if result.returncode == 0:
20
+ return SandboxStatus(container_id=result.stdout.strip()[:12],
21
+ server_name=config.server_name, status="running", policy=config.policy)
22
+ return SandboxStatus(container_id=None, server_name=config.server_name,
23
+ status=f"failed: {result.stderr.strip()}", policy=config.policy)
24
+ except FileNotFoundError:
25
+ return SandboxStatus(container_id=None, server_name=config.server_name,
26
+ status="docker not found", policy=config.policy)
27
+
28
+ def stop_sandbox(server_name: str) -> dict:
29
+ result = subprocess.run(["docker", "stop", f"mcp-shield-{server_name}"],
30
+ capture_output=True, text=True)
31
+ return {"stopped": result.returncode == 0}
32
+
33
+ def list_running_sandboxes() -> list[dict]:
34
+ result = subprocess.run(
35
+ ["docker", "ps", "--filter", "name=mcp-shield-", "--format", "json"],
36
+ capture_output=True, text=True)
37
+ containers = []
38
+ for line in result.stdout.strip().splitlines():
39
+ try:
40
+ containers.append(json.loads(line))
41
+ except json.JSONDecodeError:
42
+ pass
43
+ return containers
@@ -0,0 +1,151 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>mcp-shield dashboard</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body { background: #0d1117; color: #e6edf3; font-family: 'Segoe UI', monospace; }
10
+ header { background: #161b22; border-bottom: 1px solid #30363d; padding: 16px 32px; display: flex; align-items: center; gap: 12px; }
11
+ header h1 { font-size: 20px; font-weight: 700; }
12
+ header span { font-size: 13px; color: #8b949e; }
13
+ .badge { background: #1f6feb; color: white; font-size: 11px; padding: 2px 8px; border-radius: 12px; }
14
+
15
+ .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 24px 32px; }
16
+ .stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; }
17
+ .stat-card .label { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
18
+ .stat-card .value { font-size: 36px; font-weight: 700; }
19
+ .stat-card.total .value { color: #e6edf3; }
20
+ .stat-card.allowed .value { color: #3fb950; }
21
+ .stat-card.blocked .value { color: #f85149; }
22
+ .stat-card .sub { font-size: 12px; color: #8b949e; margin-top: 4px; }
23
+
24
+ .feed-section { padding: 0 32px 32px; }
25
+ .feed-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
26
+ .feed-header h2 { font-size: 15px; font-weight: 600; }
27
+ .live-dot { width: 8px; height: 8px; background: #3fb950; border-radius: 50%; display: inline-block; margin-right: 6px; animation: pulse 1.5s infinite; }
28
+ @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.3; } }
29
+
30
+ .feed { background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
31
+ .feed-row { display: grid; grid-template-columns: 160px 140px 160px 100px 1fr; gap: 12px; padding: 10px 16px; border-bottom: 1px solid #21262d; font-size: 13px; align-items: center; transition: background 0.3s; }
32
+ .feed-row:last-child { border-bottom: none; }
33
+ .feed-row.new-block { animation: flashRed 0.6s ease; }
34
+ .feed-row.new-allow { animation: flashGreen 0.6s ease; }
35
+ @keyframes flashRed { 0% { background: #3d1a1a; } 100% { background: transparent; } }
36
+ @keyframes flashGreen { 0% { background: #1a3d1a; } 100% { background: transparent; } }
37
+ .feed-header-row { background: #0d1117; color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
38
+ .decision-block { color: #f85149; font-weight: 600; }
39
+ .decision-allow { color: #3fb950; font-weight: 600; }
40
+ .time { color: #8b949e; }
41
+ .server { color: #79c0ff; }
42
+ .tool { color: #ffa657; font-family: monospace; }
43
+ .reason { color: #8b949e; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
44
+
45
+ .empty { text-align: center; padding: 48px; color: #8b949e; }
46
+ .refresh-info { text-align: right; font-size: 11px; color: #30363d; padding: 8px 32px; }
47
+ </style>
48
+ </head>
49
+ <body>
50
+
51
+ <header>
52
+ <span>🛡️</span>
53
+ <h1>mcp-shield</h1>
54
+ <span class="badge">LIVE</span>
55
+ <span style="margin-left:auto; font-size:12px; color:#8b949e;" id="last-updated">—</span>
56
+ </header>
57
+
58
+ <div class="stats">
59
+ <div class="stat-card total">
60
+ <div class="label">Total Decisions</div>
61
+ <div class="value" id="stat-total">—</div>
62
+ <div class="sub">all time</div>
63
+ </div>
64
+ <div class="stat-card allowed">
65
+ <div class="label">✅ Allowed</div>
66
+ <div class="value" id="stat-allowed">—</div>
67
+ <div class="sub" id="stat-allow-pct">—</div>
68
+ </div>
69
+ <div class="stat-card blocked">
70
+ <div class="label">🚫 Blocked</div>
71
+ <div class="value" id="stat-blocked">—</div>
72
+ <div class="sub" id="stat-block-pct">—</div>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="feed-section">
77
+ <div class="feed-header">
78
+ <h2><span class="live-dot"></span>Live Decision Feed</h2>
79
+ <span style="font-size:12px; color:#8b949e;">auto-refreshes every 2s</span>
80
+ </div>
81
+ <div class="feed">
82
+ <div class="feed-row feed-header-row">
83
+ <span>Time</span><span>Server</span><span>Tool</span><span>Decision</span><span>Reason</span>
84
+ </div>
85
+ <div id="feed-body">
86
+ <div class="empty">No decisions yet — start sending tool calls to /inspect</div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+
91
+ <script>
92
+ let prevTotal = 0;
93
+ let prevIds = new Set();
94
+
95
+ async function refresh() {
96
+ try {
97
+ const [statsRes, logsRes] = await Promise.all([
98
+ fetch('/audit/stats'),
99
+ fetch('/audit?limit=30')
100
+ ]);
101
+ const stats = await statsRes.json();
102
+ const logs = (await logsRes.json()).logs;
103
+
104
+ // Update stats
105
+ const total = stats.total || 0;
106
+ const allowed = stats.allowed || 0;
107
+ const blocked = stats.blocked || 0;
108
+ document.getElementById('stat-total').textContent = total;
109
+ document.getElementById('stat-allowed').textContent = allowed;
110
+ document.getElementById('stat-blocked').textContent = blocked;
111
+ document.getElementById('stat-allow-pct').textContent =
112
+ total > 0 ? `${((allowed/total)*100).toFixed(0)}% of decisions` : '—';
113
+ document.getElementById('stat-block-pct').textContent =
114
+ total > 0 ? `${((blocked/total)*100).toFixed(0)}% block rate` : '—';
115
+ document.getElementById('last-updated').textContent =
116
+ 'Updated ' + new Date().toLocaleTimeString();
117
+
118
+ // Update feed
119
+ const feedBody = document.getElementById('feed-body');
120
+ if (!logs || logs.length === 0) {
121
+ feedBody.innerHTML = '<div class="empty">No decisions yet — start sending tool calls to /inspect</div>';
122
+ return;
123
+ }
124
+
125
+ const newIds = new Set(logs.map((l, i) => `${l.timestamp}-${l.tool}-${i}`));
126
+ feedBody.innerHTML = logs.map((log, i) => {
127
+ const id = `${log.timestamp}-${log.tool}-${i}`;
128
+ const isNew = !prevIds.has(id) && prevIds.size > 0;
129
+ const isBlock = log.decision === 'BLOCK';
130
+ const time = log.timestamp.replace('T',' ').substring(0,19);
131
+ const animClass = isNew ? (isBlock ? 'new-block' : 'new-allow') : '';
132
+ return `<div class="feed-row ${animClass}">
133
+ <span class="time">${time}</span>
134
+ <span class="server">${log.server}</span>
135
+ <span class="tool">${log.tool}</span>
136
+ <span class="${isBlock ? 'decision-block' : 'decision-allow'}">${isBlock ? '🚫 BLOCK' : '✅ ALLOW'}</span>
137
+ <span class="reason" title="${log.reason}">${log.reason}</span>
138
+ </div>`;
139
+ }).join('');
140
+
141
+ prevIds = newIds;
142
+ } catch(e) {
143
+ document.getElementById('last-updated').textContent = 'API unreachable';
144
+ }
145
+ }
146
+
147
+ refresh();
148
+ setInterval(refresh, 2000);
149
+ </script>
150
+ </body>
151
+ </html>