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.
- mcpshield_runtime-0.1.0.dist-info/METADATA +279 -0
- mcpshield_runtime-0.1.0.dist-info/RECORD +17 -0
- mcpshield_runtime-0.1.0.dist-info/WHEEL +5 -0
- mcpshield_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- mcpshield_runtime-0.1.0.dist-info/top_level.txt +1 -0
- runtime/__init__.py +0 -0
- runtime/api/__init__.py +0 -0
- runtime/api/main.py +73 -0
- runtime/audit_logger.py +67 -0
- runtime/cli.py +149 -0
- runtime/models.py +27 -0
- runtime/policy_engine.py +56 -0
- runtime/risk_scorer.py +30 -0
- runtime/sandbox/__init__.py +0 -0
- runtime/sandbox/base.py +15 -0
- runtime/sandbox/docker_backend.py +43 -0
- runtime/static/dashboard.html +151 -0
|
@@ -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
|
+

|
|
32
|
+

|
|
33
|
+

|
|
34
|
+

|
|
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 @@
|
|
|
1
|
+
runtime
|
runtime/__init__.py
ADDED
|
File without changes
|
runtime/api/__init__.py
ADDED
|
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")
|
runtime/audit_logger.py
ADDED
|
@@ -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
|
runtime/policy_engine.py
ADDED
|
@@ -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
|
runtime/sandbox/base.py
ADDED
|
@@ -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>
|