@permission-protocol/mcp-guard 0.1.0
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.
- package/README.md +224 -0
- package/assets/demo.gif +0 -0
- package/bin/mcp-guard +2 -0
- package/demo/_demo-scenario.sh +43 -0
- package/dist/example/server.d.ts +1 -0
- package/dist/example/server.js +54 -0
- package/dist/example/server.js.map +1 -0
- package/dist/src/approval-server.d.ts +4 -0
- package/dist/src/approval-server.js +128 -0
- package/dist/src/approval-server.js.map +1 -0
- package/dist/src/approval-ui.d.ts +1 -0
- package/dist/src/approval-ui.js +158 -0
- package/dist/src/approval-ui.js.map +1 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +82 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +13 -0
- package/dist/src/config.js +48 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/engine.d.ts +7 -0
- package/dist/src/engine.js +25 -0
- package/dist/src/engine.js.map +1 -0
- package/dist/src/pending.d.ts +20 -0
- package/dist/src/pending.js +44 -0
- package/dist/src/pending.js.map +1 -0
- package/dist/src/proxy.d.ts +2 -0
- package/dist/src/proxy.js +147 -0
- package/dist/src/proxy.js.map +1 -0
- package/dist/src/receipt.d.ts +15 -0
- package/dist/src/receipt.js +34 -0
- package/dist/src/receipt.js.map +1 -0
- package/dist/src/tests/config.test.d.ts +1 -0
- package/dist/src/tests/config.test.js +63 -0
- package/dist/src/tests/config.test.js.map +1 -0
- package/dist/src/tests/engine.test.d.ts +1 -0
- package/dist/src/tests/engine.test.js +46 -0
- package/dist/src/tests/engine.test.js.map +1 -0
- package/dist/src/tests/receipt.test.d.ts +1 -0
- package/dist/src/tests/receipt.test.js +44 -0
- package/dist/src/tests/receipt.test.js.map +1 -0
- package/docs/index.html +771 -0
- package/example/README.md +38 -0
- package/example/pp.config.yaml +15 -0
- package/example/server.ts +52 -0
- package/example/test.sh +64 -0
- package/package.json +29 -0
- package/permission-protocol-mcp-guard-0.1.0.tgz +0 -0
- package/src/approval-server.ts +136 -0
- package/src/approval-ui.ts +154 -0
- package/src/cli.ts +84 -0
- package/src/config.ts +68 -0
- package/src/engine.ts +31 -0
- package/src/pending.ts +65 -0
- package/src/proxy.ts +173 -0
- package/src/receipt.ts +52 -0
- package/src/tests/config.test.ts +61 -0
- package/src/tests/engine.test.ts +47 -0
- package/src/tests/receipt.test.ts +44 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# MCP Guard Example
|
|
2
|
+
|
|
3
|
+
Run this demo in 2 minutes.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node.js 20+
|
|
8
|
+
- npm
|
|
9
|
+
|
|
10
|
+
## Steps
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# 1. Install dependencies
|
|
14
|
+
cd /path/to/mcp-guard
|
|
15
|
+
npm install
|
|
16
|
+
|
|
17
|
+
# 2. Build
|
|
18
|
+
npm run build
|
|
19
|
+
|
|
20
|
+
# 3. Run the demo
|
|
21
|
+
bash example/test.sh
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## What happens
|
|
25
|
+
|
|
26
|
+
The demo sends three `tools/call` requests through `mcp-guard`:
|
|
27
|
+
|
|
28
|
+
| Tool | Config Rule | Result |
|
|
29
|
+
|------|-----------|--------|
|
|
30
|
+
| `send_email` | allow | ✅ Forwarded to server, response returned |
|
|
31
|
+
| `delete_user_data` | block | 🚫 Blocked, JSON-RPC error returned |
|
|
32
|
+
| `deploy_production` | require_approval | ⏸️ Held, JSON-RPC error returned |
|
|
33
|
+
|
|
34
|
+
Each decision generates a receipt written to stderr and `pp-receipts.jsonl`.
|
|
35
|
+
|
|
36
|
+
## Config
|
|
37
|
+
|
|
38
|
+
See `pp.config.yaml` for the policy rules used in this demo.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
default_action: allow
|
|
2
|
+
mode: enforce # or "observe" to log decisions without blocking
|
|
3
|
+
|
|
4
|
+
rules:
|
|
5
|
+
- id: allow-company-email
|
|
6
|
+
tool: send_email
|
|
7
|
+
action: allow
|
|
8
|
+
|
|
9
|
+
- id: block-delete-user-data
|
|
10
|
+
tool: delete_user_data
|
|
11
|
+
action: block
|
|
12
|
+
|
|
13
|
+
- id: require-approval-prod-deploy
|
|
14
|
+
tool: deploy_production
|
|
15
|
+
action: require_approval
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny MCP server for testing mcp-guard.
|
|
3
|
+
* Responds to tools/call, tools/list, and initialize.
|
|
4
|
+
* Newline-delimited JSON-RPC over stdio.
|
|
5
|
+
*/
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
|
|
8
|
+
const TOOLS = [
|
|
9
|
+
{ name: 'send_email', description: 'Send an email', inputSchema: { type: 'object', properties: { to: { type: 'string' }, body: { type: 'string' } } } },
|
|
10
|
+
{ name: 'delete_user_data', description: 'Delete all user data', inputSchema: { type: 'object', properties: { user_id: { type: 'string' } } } },
|
|
11
|
+
{ name: 'deploy_production', description: 'Deploy to production', inputSchema: { type: 'object', properties: { version: { type: 'string' } } } },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const rl = createInterface({ input: process.stdin });
|
|
15
|
+
|
|
16
|
+
rl.on('line', (line) => {
|
|
17
|
+
if (!line.trim()) return;
|
|
18
|
+
|
|
19
|
+
let msg: any;
|
|
20
|
+
try {
|
|
21
|
+
msg = JSON.parse(line);
|
|
22
|
+
} catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (msg.method === 'initialize') {
|
|
27
|
+
respond(msg.id, {
|
|
28
|
+
protocolVersion: '2024-11-05',
|
|
29
|
+
capabilities: { tools: {} },
|
|
30
|
+
serverInfo: { name: 'example-server', version: '0.1.0' },
|
|
31
|
+
});
|
|
32
|
+
} else if (msg.method === 'tools/list') {
|
|
33
|
+
respond(msg.id, { tools: TOOLS });
|
|
34
|
+
} else if (msg.method === 'tools/call') {
|
|
35
|
+
const toolName = msg.params?.name ?? 'unknown';
|
|
36
|
+
respond(msg.id, {
|
|
37
|
+
content: [{ type: 'text', text: `Executed ${toolName} successfully` }],
|
|
38
|
+
});
|
|
39
|
+
} else if (msg.method === 'notifications/initialized') {
|
|
40
|
+
// No response needed for notifications
|
|
41
|
+
} else {
|
|
42
|
+
respondError(msg.id, -32601, `Method not found: ${msg.method}`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function respond(id: any, result: any) {
|
|
47
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function respondError(id: any, code: number, message: string) {
|
|
51
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
|
|
52
|
+
}
|
package/example/test.sh
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
|
6
|
+
|
|
7
|
+
echo "=== MCP Guard Demo ==="
|
|
8
|
+
echo ""
|
|
9
|
+
echo "Building..."
|
|
10
|
+
cd "$ROOT_DIR"
|
|
11
|
+
npm run build --silent 2>/dev/null
|
|
12
|
+
|
|
13
|
+
echo "Sending test requests through mcp-guard proxy..."
|
|
14
|
+
echo ""
|
|
15
|
+
|
|
16
|
+
# Create a temp file for responses
|
|
17
|
+
RESPONSES=$(mktemp)
|
|
18
|
+
RECEIPTS=$(mktemp)
|
|
19
|
+
|
|
20
|
+
# Send three tool calls through the proxy
|
|
21
|
+
{
|
|
22
|
+
# 1. Initialize
|
|
23
|
+
echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}'
|
|
24
|
+
sleep 0.3
|
|
25
|
+
|
|
26
|
+
# 2. Allowed: send_email
|
|
27
|
+
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"send_email","arguments":{"to":"user@example.com","body":"Hello!"}}}'
|
|
28
|
+
sleep 0.3
|
|
29
|
+
|
|
30
|
+
# 3. Blocked: delete_user_data
|
|
31
|
+
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_user_data","arguments":{"user_id":"123"}}}'
|
|
32
|
+
sleep 0.3
|
|
33
|
+
|
|
34
|
+
# 4. Held: deploy_production
|
|
35
|
+
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"deploy_production","arguments":{"version":"1.0.0"}}}'
|
|
36
|
+
sleep 0.3
|
|
37
|
+
} | node dist/src/cli.js --config example/pp.config.yaml --agent-id demo-agent -- node dist/example/server.js > "$RESPONSES" 2>"$RECEIPTS"
|
|
38
|
+
|
|
39
|
+
echo "--- Responses (stdout) ---"
|
|
40
|
+
cat "$RESPONSES" | while IFS= read -r line; do
|
|
41
|
+
echo "$line" | python3 -m json.tool 2>/dev/null || echo "$line"
|
|
42
|
+
done
|
|
43
|
+
|
|
44
|
+
echo ""
|
|
45
|
+
echo "--- Receipts (stderr) ---"
|
|
46
|
+
grep "receipt:" "$RECEIPTS" | sed 's/\[mcp-guard\] receipt: //' | while IFS= read -r line; do
|
|
47
|
+
echo "$line" | python3 -m json.tool 2>/dev/null || echo "$line"
|
|
48
|
+
done
|
|
49
|
+
|
|
50
|
+
echo ""
|
|
51
|
+
echo "--- Guard Log ---"
|
|
52
|
+
grep -v "receipt:" "$RECEIPTS" || true
|
|
53
|
+
|
|
54
|
+
rm -f "$RESPONSES" "$RECEIPTS"
|
|
55
|
+
echo ""
|
|
56
|
+
echo "=== Approval Flow ==="
|
|
57
|
+
echo ""
|
|
58
|
+
echo "To try the approval UI, run:"
|
|
59
|
+
echo " node dist/src/cli.js --config example/pp.config.yaml --approval-port 3100 --agent-id demo-agent -- node dist/example/server.js"
|
|
60
|
+
echo ""
|
|
61
|
+
echo "Then open http://localhost:3100 and send a require_approval tool call."
|
|
62
|
+
echo "The proxy will hold the response until you approve or deny in the UI."
|
|
63
|
+
echo ""
|
|
64
|
+
echo "Done!"
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@permission-protocol/mcp-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP Guard blocks dangerous AI agent actions using a simple YAML config file.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"main": "dist/src/proxy.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"mcp-guard": "bin/mcp-guard"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"test": "node --test dist/src/tests/*.test.js",
|
|
15
|
+
"demo": "npm run build && bash example/test.sh",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["mcp", "guard", "proxy", "ai", "safety", "permission-protocol"],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"yaml": "^2.7.0",
|
|
22
|
+
"uuid": "^11.1.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
26
|
+
"@types/uuid": "^10.0.0",
|
|
27
|
+
"typescript": "^5.7.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
+
import { getPending, getAction, resolveAction, recentResolved } from './pending.js';
|
|
3
|
+
import { getApprovalHTML } from './approval-ui.js';
|
|
4
|
+
import type { Receipt } from './receipt.js';
|
|
5
|
+
|
|
6
|
+
/** Recent receipts store (ring buffer) */
|
|
7
|
+
const recentReceipts: Receipt[] = [];
|
|
8
|
+
const MAX_RECEIPTS = 50;
|
|
9
|
+
|
|
10
|
+
export function pushReceipt(receipt: Receipt): void {
|
|
11
|
+
recentReceipts.push(receipt);
|
|
12
|
+
if (recentReceipts.length > MAX_RECEIPTS) recentReceipts.shift();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Callback invoked when an action is approved — proxy registers this */
|
|
16
|
+
let onApproveCallback: ((id: string) => void) | null = null;
|
|
17
|
+
|
|
18
|
+
export function setOnApprove(cb: (id: string) => void): void {
|
|
19
|
+
onApproveCallback = cb;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseBody(req: IncomingMessage): Promise<string> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const chunks: Buffer[] = [];
|
|
25
|
+
req.on('data', (c: Buffer) => chunks.push(c));
|
|
26
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
|
27
|
+
req.on('error', reject);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function json(res: ServerResponse, status: number, data: unknown): void {
|
|
32
|
+
const body = JSON.stringify(data);
|
|
33
|
+
res.writeHead(status, {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
'Access-Control-Allow-Origin': '*',
|
|
36
|
+
});
|
|
37
|
+
res.end(body);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function text(res: ServerResponse, status: number, msg: string): void {
|
|
41
|
+
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
|
42
|
+
res.end(msg);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function startApprovalServer(port: number): void {
|
|
46
|
+
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
47
|
+
const url = req.url ?? '/';
|
|
48
|
+
const method = req.method ?? 'GET';
|
|
49
|
+
|
|
50
|
+
// CORS preflight
|
|
51
|
+
if (method === 'OPTIONS') {
|
|
52
|
+
res.writeHead(204, {
|
|
53
|
+
'Access-Control-Allow-Origin': '*',
|
|
54
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
55
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
56
|
+
});
|
|
57
|
+
res.end();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// GET / — Approval UI
|
|
63
|
+
if (method === 'GET' && url === '/') {
|
|
64
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
65
|
+
res.end(getApprovalHTML());
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// GET /api/pending
|
|
70
|
+
if (method === 'GET' && url === '/api/pending') {
|
|
71
|
+
const pending = getPending().map(p => ({
|
|
72
|
+
id: p.id,
|
|
73
|
+
timestamp: p.timestamp,
|
|
74
|
+
tool_name: p.tool_name,
|
|
75
|
+
tool_args: p.tool_args,
|
|
76
|
+
agent_id: p.agent_id,
|
|
77
|
+
rule_id: p.rule_id,
|
|
78
|
+
status: p.status,
|
|
79
|
+
}));
|
|
80
|
+
json(res, 200, pending);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// POST /api/approve/:id
|
|
85
|
+
const approveMatch = url.match(/^\/api\/approve\/([a-f0-9-]+)$/);
|
|
86
|
+
if (method === 'POST' && approveMatch) {
|
|
87
|
+
const id = approveMatch[1];
|
|
88
|
+
const action = resolveAction(id, 'approved');
|
|
89
|
+
if (!action) {
|
|
90
|
+
json(res, 404, { error: 'Not found or already resolved' });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
process.stderr.write(`[mcp-guard] Approved: ${action.tool_name} (${id})\n`);
|
|
94
|
+
// Notify proxy to forward the original request
|
|
95
|
+
if (onApproveCallback) onApproveCallback(id);
|
|
96
|
+
json(res, 200, { id, status: 'approved' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// POST /api/deny/:id
|
|
101
|
+
const denyMatch = url.match(/^\/api\/deny\/([a-f0-9-]+)$/);
|
|
102
|
+
if (method === 'POST' && denyMatch) {
|
|
103
|
+
const id = denyMatch[1];
|
|
104
|
+
const action = resolveAction(id, 'denied');
|
|
105
|
+
if (!action) {
|
|
106
|
+
json(res, 404, { error: 'Not found or already resolved' });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
process.stderr.write(`[mcp-guard] Denied: ${action.tool_name} (${id})\n`);
|
|
110
|
+
// Return JSON-RPC error to the waiting caller
|
|
111
|
+
const errResp = JSON.stringify({
|
|
112
|
+
jsonrpc: '2.0',
|
|
113
|
+
id: action.jsonrpcId,
|
|
114
|
+
error: { code: -32002, message: 'Denied by administrator' },
|
|
115
|
+
});
|
|
116
|
+
action.resolve(errResp);
|
|
117
|
+
json(res, 200, { id, status: 'denied' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// GET /api/receipts
|
|
122
|
+
if (method === 'GET' && url === '/api/receipts') {
|
|
123
|
+
json(res, 200, recentReceipts.slice(-50).reverse());
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
text(res, 404, 'Not found');
|
|
128
|
+
} catch (err: any) {
|
|
129
|
+
text(res, 500, err.message ?? 'Internal error');
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
server.listen(port, () => {
|
|
134
|
+
process.stderr.write(`[mcp-guard] Approval UI: http://localhost:${port}\n`);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
export function getApprovalHTML(): string {
|
|
2
|
+
return `<!DOCTYPE html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>MCP Guard — Approval Queue</title>
|
|
8
|
+
<style>
|
|
9
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
10
|
+
body {
|
|
11
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
12
|
+
background: #0d1117; color: #c9d1d9; line-height: 1.5;
|
|
13
|
+
padding: 24px; max-width: 900px; margin: 0 auto;
|
|
14
|
+
}
|
|
15
|
+
h1 { color: #58a6ff; font-size: 24px; margin-bottom: 4px; }
|
|
16
|
+
.subtitle { color: #8b949e; font-size: 14px; margin-bottom: 24px; }
|
|
17
|
+
.badge {
|
|
18
|
+
display: inline-block; padding: 2px 8px; border-radius: 12px;
|
|
19
|
+
font-size: 12px; font-weight: 600; text-transform: uppercase;
|
|
20
|
+
}
|
|
21
|
+
.badge-pending { background: #d29922; color: #0d1117; }
|
|
22
|
+
.badge-approved { background: #238636; color: #fff; }
|
|
23
|
+
.badge-denied { background: #da3633; color: #fff; }
|
|
24
|
+
.badge-allowed { background: #238636; color: #fff; }
|
|
25
|
+
.badge-blocked { background: #da3633; color: #fff; }
|
|
26
|
+
.badge-held_for_approval { background: #d29922; color: #0d1117; }
|
|
27
|
+
.card {
|
|
28
|
+
border: 1px solid #30363d; border-radius: 8px; padding: 16px;
|
|
29
|
+
margin-bottom: 12px; background: #161b22;
|
|
30
|
+
}
|
|
31
|
+
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
32
|
+
.tool-name { font-size: 18px; font-weight: 700; color: #f0f6fc; }
|
|
33
|
+
.meta { font-size: 13px; color: #8b949e; margin-bottom: 8px; }
|
|
34
|
+
.meta span { margin-right: 16px; }
|
|
35
|
+
.args-toggle {
|
|
36
|
+
background: none; border: 1px solid #30363d; color: #58a6ff;
|
|
37
|
+
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;
|
|
38
|
+
margin-bottom: 8px;
|
|
39
|
+
}
|
|
40
|
+
.args-toggle:hover { border-color: #58a6ff; }
|
|
41
|
+
.args-content {
|
|
42
|
+
display: none; background: #0d1117; border: 1px solid #30363d;
|
|
43
|
+
border-radius: 4px; padding: 12px; margin-bottom: 12px;
|
|
44
|
+
font-family: 'SF Mono', Consolas, monospace; font-size: 13px;
|
|
45
|
+
overflow-x: auto; white-space: pre-wrap; word-break: break-all;
|
|
46
|
+
}
|
|
47
|
+
.args-content.open { display: block; }
|
|
48
|
+
.actions { display: flex; gap: 8px; }
|
|
49
|
+
.btn {
|
|
50
|
+
padding: 6px 16px; border-radius: 6px; border: none;
|
|
51
|
+
font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity 0.15s;
|
|
52
|
+
}
|
|
53
|
+
.btn:hover { opacity: 0.85; }
|
|
54
|
+
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
55
|
+
.btn-approve { background: #238636; color: #fff; }
|
|
56
|
+
.btn-deny { background: #da3633; color: #fff; }
|
|
57
|
+
.empty {
|
|
58
|
+
text-align: center; padding: 40px; color: #8b949e;
|
|
59
|
+
border: 1px dashed #30363d; border-radius: 8px; margin-bottom: 24px;
|
|
60
|
+
}
|
|
61
|
+
h2 { color: #c9d1d9; font-size: 18px; margin: 24px 0 12px; }
|
|
62
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
63
|
+
th { text-align: left; padding: 8px; border-bottom: 2px solid #30363d; color: #8b949e; font-weight: 600; }
|
|
64
|
+
td { padding: 8px; border-bottom: 1px solid #21262d; }
|
|
65
|
+
.pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #3fb950; margin-right: 8px; animation: pulse 2s infinite; }
|
|
66
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
|
|
67
|
+
.status-bar { display: flex; align-items: center; font-size: 12px; color: #8b949e; margin-bottom: 16px; }
|
|
68
|
+
</style>
|
|
69
|
+
</head>
|
|
70
|
+
<body>
|
|
71
|
+
<h1>MCP Guard — Approval Queue</h1>
|
|
72
|
+
<p class="subtitle">Human-in-the-loop approval for AI tool calls</p>
|
|
73
|
+
<div class="status-bar"><span class="pulse"></span> Polling every 3s</div>
|
|
74
|
+
|
|
75
|
+
<div id="pending"></div>
|
|
76
|
+
<h2>Recent Decisions</h2>
|
|
77
|
+
<div id="receipts"></div>
|
|
78
|
+
|
|
79
|
+
<script>
|
|
80
|
+
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
81
|
+
|
|
82
|
+
function toggleArgs(btn) {
|
|
83
|
+
const c = btn.nextElementSibling;
|
|
84
|
+
c.classList.toggle('open');
|
|
85
|
+
btn.textContent = c.classList.contains('open') ? '▾ Hide arguments' : '▸ Show arguments';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function act(id, action) {
|
|
89
|
+
const btns = document.querySelectorAll('[data-id="'+id+'"]');
|
|
90
|
+
btns.forEach(b => b.disabled = true);
|
|
91
|
+
try {
|
|
92
|
+
const r = await fetch('/api/' + action + '/' + id, { method: 'POST' });
|
|
93
|
+
if (!r.ok) { const e = await r.text(); alert('Error: ' + e); }
|
|
94
|
+
} catch(e) { alert('Network error'); }
|
|
95
|
+
refresh();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function refresh() {
|
|
99
|
+
try {
|
|
100
|
+
const [pRes, rRes] = await Promise.all([
|
|
101
|
+
fetch('/api/pending'), fetch('/api/receipts')
|
|
102
|
+
]);
|
|
103
|
+
const pending = await pRes.json();
|
|
104
|
+
const receipts = await rRes.json();
|
|
105
|
+
|
|
106
|
+
const pEl = document.getElementById('pending');
|
|
107
|
+
if (pending.length === 0) {
|
|
108
|
+
pEl.innerHTML = '<div class="empty">No pending approvals</div>';
|
|
109
|
+
} else {
|
|
110
|
+
pEl.innerHTML = pending.map(p => \`
|
|
111
|
+
<div class="card">
|
|
112
|
+
<div class="card-header">
|
|
113
|
+
<span class="tool-name">\${esc(p.tool_name)}</span>
|
|
114
|
+
<span class="badge badge-pending">pending</span>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="meta">
|
|
117
|
+
<span>Agent: <strong>\${esc(p.agent_id)}</strong></span>
|
|
118
|
+
<span>Rule: <strong>\${esc(p.rule_id)}</strong></span>
|
|
119
|
+
<span>\${esc(new Date(p.timestamp).toLocaleTimeString())}</span>
|
|
120
|
+
</div>
|
|
121
|
+
<button class="args-toggle" onclick="toggleArgs(this)">▸ Show arguments</button>
|
|
122
|
+
<div class="args-content">\${esc(JSON.stringify(p.tool_args, null, 2))}</div>
|
|
123
|
+
<div class="actions">
|
|
124
|
+
<button class="btn btn-approve" data-id="\${p.id}" onclick="act('\${p.id}','approve')">✅ Approve</button>
|
|
125
|
+
<button class="btn btn-deny" data-id="\${p.id}" onclick="act('\${p.id}','deny')">❌ Deny</button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
\`).join('');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const rEl = document.getElementById('receipts');
|
|
132
|
+
if (receipts.length === 0) {
|
|
133
|
+
rEl.innerHTML = '<div class="empty">No recent decisions</div>';
|
|
134
|
+
} else {
|
|
135
|
+
rEl.innerHTML = \`<table>
|
|
136
|
+
<thead><tr><th>Time</th><th>Tool</th><th>Decision</th><th>Agent</th><th>Rule</th></tr></thead>
|
|
137
|
+
<tbody>\${receipts.map(r => \`<tr>
|
|
138
|
+
<td>\${esc(new Date(r.timestamp).toLocaleTimeString())}</td>
|
|
139
|
+
<td><strong>\${esc(r.tool_name)}</strong></td>
|
|
140
|
+
<td><span class="badge badge-\${r.decision}">\${esc(r.decision)}</span></td>
|
|
141
|
+
<td>\${esc(r.agent_id)}</td>
|
|
142
|
+
<td>\${esc(r.rule_id || '—')}</td>
|
|
143
|
+
</tr>\`).join('')}</tbody>
|
|
144
|
+
</table>\`;
|
|
145
|
+
}
|
|
146
|
+
} catch(e) { /* retry next cycle */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
refresh();
|
|
150
|
+
setInterval(refresh, 3000);
|
|
151
|
+
</script>
|
|
152
|
+
</body>
|
|
153
|
+
</html>`;
|
|
154
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { loadConfig } from './config.js';
|
|
2
|
+
import { startProxy } from './proxy.js';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export function main(): void {
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
if (args[0] === 'demo') {
|
|
12
|
+
// When running via npx @permission-protocol/mcp-guard demo
|
|
13
|
+
const scriptPath = join(__dirname, '../../example/test.sh');
|
|
14
|
+
const result = spawnSync('bash', [scriptPath], { stdio: 'inherit' });
|
|
15
|
+
process.exit(result.status ?? 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let configPath = './pp.config.yaml';
|
|
19
|
+
let agentId = 'unknown';
|
|
20
|
+
let modeOverride: 'enforce' | 'observe' | undefined;
|
|
21
|
+
let approvalPort: number | undefined;
|
|
22
|
+
let serverCommand: string[] = [];
|
|
23
|
+
|
|
24
|
+
const dashDashIndex = args.indexOf('--');
|
|
25
|
+
const cliArgs = dashDashIndex >= 0 ? args.slice(0, dashDashIndex) : args;
|
|
26
|
+
serverCommand = dashDashIndex >= 0 ? args.slice(dashDashIndex + 1) : [];
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < cliArgs.length; i++) {
|
|
29
|
+
if (cliArgs[i] === '--config' && cliArgs[i + 1]) {
|
|
30
|
+
configPath = cliArgs[++i];
|
|
31
|
+
} else if (cliArgs[i] === '--agent-id' && cliArgs[i + 1]) {
|
|
32
|
+
agentId = cliArgs[++i];
|
|
33
|
+
} else if (cliArgs[i] === '--mode' && cliArgs[i + 1]) {
|
|
34
|
+
const m = cliArgs[++i];
|
|
35
|
+
if (m === 'enforce' || m === 'observe') modeOverride = m;
|
|
36
|
+
} else if (cliArgs[i] === '--approval-port' && cliArgs[i + 1]) {
|
|
37
|
+
approvalPort = parseInt(cliArgs[++i], 10);
|
|
38
|
+
if (isNaN(approvalPort)) {
|
|
39
|
+
process.stderr.write('[mcp-guard] Error: --approval-port must be a number\n');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
} else if (cliArgs[i] === '--help' || cliArgs[i] === '-h') {
|
|
43
|
+
printHelp();
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (serverCommand.length === 0) {
|
|
49
|
+
process.stderr.write('[mcp-guard] Error: No server command. Use: mcp-guard [options] -- <server command>\n');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const config = loadConfig(configPath);
|
|
54
|
+
if (modeOverride) config.mode = modeOverride;
|
|
55
|
+
process.stderr.write(`[mcp-guard] Loaded ${config.rules.length} rules (default: ${config.default_action}, mode: ${config.mode})\n`);
|
|
56
|
+
process.stderr.write(`[mcp-guard] Starting server: ${serverCommand.join(' ')}\n`);
|
|
57
|
+
|
|
58
|
+
startProxy(config, agentId, serverCommand, approvalPort);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function printHelp(): void {
|
|
62
|
+
const help = `
|
|
63
|
+
mcp-guard — MCP tool-call policy proxy
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
mcp-guard [options] -- <server command>
|
|
67
|
+
|
|
68
|
+
Options:
|
|
69
|
+
--config <path> Path to config file (default: ./pp.config.yaml)
|
|
70
|
+
--agent-id <id> Agent identifier for receipts (default: "unknown")
|
|
71
|
+
--mode <mode> enforce or observe (overrides config; default: enforce)
|
|
72
|
+
--approval-port <port> Enable approval UI on this port (e.g. 3100)
|
|
73
|
+
-h, --help Show this help
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
mcp-guard --config pp.config.yaml -- node my-mcp-server.js
|
|
77
|
+
|
|
78
|
+
# With approval UI:
|
|
79
|
+
mcp-guard --config pp.config.yaml --approval-port 3100 -- node my-mcp-server.js
|
|
80
|
+
`.trim();
|
|
81
|
+
console.log(help);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
main();
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parse } from 'yaml';
|
|
3
|
+
|
|
4
|
+
export type Action = 'allow' | 'block' | 'require_approval';
|
|
5
|
+
|
|
6
|
+
export interface Rule {
|
|
7
|
+
id: string;
|
|
8
|
+
tool: string;
|
|
9
|
+
action: Action;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Mode = 'enforce' | 'observe';
|
|
13
|
+
|
|
14
|
+
export interface Config {
|
|
15
|
+
default_action: Action;
|
|
16
|
+
mode: Mode;
|
|
17
|
+
rules: Rule[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const VALID_ACTIONS: Action[] = ['allow', 'block', 'require_approval'];
|
|
21
|
+
|
|
22
|
+
export function loadConfig(configPath: string): Config {
|
|
23
|
+
let raw: string;
|
|
24
|
+
try {
|
|
25
|
+
raw = readFileSync(configPath, 'utf-8');
|
|
26
|
+
} catch (err: any) {
|
|
27
|
+
throw new Error(`Failed to read config file: ${configPath} (${err.code ?? err.message})`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const parsed = parse(raw);
|
|
31
|
+
|
|
32
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
33
|
+
throw new Error('Config must be a YAML object');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!VALID_ACTIONS.includes(parsed.default_action)) {
|
|
37
|
+
throw new Error(`Invalid default_action: "${parsed.default_action}". Must be one of: ${VALID_ACTIONS.join(', ')}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const VALID_MODES: Mode[] = ['enforce', 'observe'];
|
|
41
|
+
const mode: Mode = parsed.mode ?? 'enforce';
|
|
42
|
+
if (!VALID_MODES.includes(mode)) {
|
|
43
|
+
throw new Error(`Invalid mode: "${parsed.mode}". Must be one of: ${VALID_MODES.join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!Array.isArray(parsed.rules)) {
|
|
47
|
+
throw new Error('Config must include a "rules" array');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const rules: Rule[] = parsed.rules.map((r: any, i: number) => {
|
|
51
|
+
if (!r.id || typeof r.id !== 'string') {
|
|
52
|
+
throw new Error(`Rule ${i} missing valid "id"`);
|
|
53
|
+
}
|
|
54
|
+
if (!r.tool || typeof r.tool !== 'string') {
|
|
55
|
+
throw new Error(`Rule ${i} missing valid "tool"`);
|
|
56
|
+
}
|
|
57
|
+
if (!VALID_ACTIONS.includes(r.action)) {
|
|
58
|
+
throw new Error(`Rule ${i} has invalid action: "${r.action}"`);
|
|
59
|
+
}
|
|
60
|
+
return { id: r.id, tool: r.tool, action: r.action as Action };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
default_action: parsed.default_action as Action,
|
|
65
|
+
mode,
|
|
66
|
+
rules,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Config } from './config.js';
|
|
2
|
+
|
|
3
|
+
export interface Decision {
|
|
4
|
+
decision: 'allowed' | 'blocked' | 'held_for_approval';
|
|
5
|
+
rule_id: string | null;
|
|
6
|
+
reason: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ACTION_TO_DECISION = {
|
|
10
|
+
allow: 'allowed',
|
|
11
|
+
block: 'blocked',
|
|
12
|
+
require_approval: 'held_for_approval',
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export function evaluate(toolName: string, _toolArgs: Record<string, unknown> | undefined, config: Config): Decision {
|
|
16
|
+
for (const rule of config.rules) {
|
|
17
|
+
if (rule.tool === toolName) {
|
|
18
|
+
return {
|
|
19
|
+
decision: ACTION_TO_DECISION[rule.action],
|
|
20
|
+
rule_id: rule.id,
|
|
21
|
+
reason: `Matched rule "${rule.id}"`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
decision: ACTION_TO_DECISION[config.default_action],
|
|
28
|
+
rule_id: null,
|
|
29
|
+
reason: `No matching rule; default action "${config.default_action}"`,
|
|
30
|
+
};
|
|
31
|
+
}
|