@permission-protocol/mcp-guard 0.1.0 → 0.2.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/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +101 -59
- package/SECURITY.md +40 -0
- package/dist/src/approval-server.d.ts +69 -3
- package/dist/src/approval-server.js +345 -86
- package/dist/src/approval-server.js.map +1 -1
- package/dist/src/approval-ui.d.ts +5 -0
- package/dist/src/approval-ui.js +120 -81
- package/dist/src/approval-ui.js.map +1 -1
- package/dist/src/classify.d.ts +29 -0
- package/dist/src/classify.js +107 -0
- package/dist/src/classify.js.map +1 -0
- package/dist/src/cli.js +49 -12
- package/dist/src/cli.js.map +1 -1
- package/dist/src/config.d.ts +21 -1
- package/dist/src/config.js +92 -5
- package/dist/src/config.js.map +1 -1
- package/dist/src/enforcement/index.d.ts +12 -0
- package/dist/src/enforcement/index.js +53 -0
- package/dist/src/enforcement/index.js.map +1 -0
- package/dist/src/enforcement/policy.d.ts +29 -0
- package/dist/src/enforcement/policy.js +60 -0
- package/dist/src/enforcement/policy.js.map +1 -0
- package/dist/src/enforcement/types.d.ts +19 -0
- package/dist/src/enforcement/types.js +3 -0
- package/dist/src/enforcement/types.js.map +1 -0
- package/dist/src/enforcement/verifier.d.ts +4 -0
- package/dist/src/enforcement/verifier.js +110 -0
- package/dist/src/enforcement/verifier.js.map +1 -0
- package/dist/src/engine.d.ts +11 -2
- package/dist/src/engine.js +20 -14
- package/dist/src/engine.js.map +1 -1
- package/dist/src/frame.d.ts +67 -0
- package/dist/src/frame.js +113 -0
- package/dist/src/frame.js.map +1 -0
- package/dist/src/pending.d.ts +108 -12
- package/dist/src/pending.js +174 -24
- package/dist/src/pending.js.map +1 -1
- package/dist/src/proxy.d.ts +29 -2
- package/dist/src/proxy.js +274 -105
- package/dist/src/proxy.js.map +1 -1
- package/dist/src/receipt-verify.d.ts +36 -0
- package/dist/src/receipt-verify.js +88 -0
- package/dist/src/receipt-verify.js.map +1 -0
- package/dist/src/receipt.d.ts +81 -7
- package/dist/src/receipt.js +100 -17
- package/dist/src/receipt.js.map +1 -1
- package/dist/src/signer.d.ts +38 -0
- package/dist/src/signer.js +112 -0
- package/dist/src/signer.js.map +1 -0
- package/dist/src/verify-cli.d.ts +1 -0
- package/dist/src/verify-cli.js +133 -0
- package/dist/src/verify-cli.js.map +1 -0
- package/dist/src/verify-core.d.ts +22 -0
- package/dist/src/verify-core.js +50 -0
- package/dist/src/verify-core.js.map +1 -0
- package/dist/src/webhook.d.ts +35 -0
- package/dist/src/webhook.js +57 -0
- package/dist/src/webhook.js.map +1 -0
- package/example/test.sh +5 -7
- package/package.json +33 -7
- package/templates/github-actions/receipt-gate.yml +64 -0
- package/assets/demo.gif +0 -0
- package/demo/_demo-scenario.sh +0 -43
- package/dist/src/tests/config.test.d.ts +0 -1
- package/dist/src/tests/config.test.js +0 -63
- package/dist/src/tests/config.test.js.map +0 -1
- package/dist/src/tests/engine.test.d.ts +0 -1
- package/dist/src/tests/engine.test.js +0 -46
- package/dist/src/tests/engine.test.js.map +0 -1
- package/dist/src/tests/receipt.test.d.ts +0 -1
- package/dist/src/tests/receipt.test.js +0 -44
- package/dist/src/tests/receipt.test.js.map +0 -1
- package/docs/index.html +0 -771
- package/permission-protocol-mcp-guard-0.1.0.tgz +0 -0
- package/src/approval-server.ts +0 -136
- package/src/approval-ui.ts +0 -154
- package/src/cli.ts +0 -84
- package/src/config.ts +0 -68
- package/src/engine.ts +0 -31
- package/src/pending.ts +0 -65
- package/src/proxy.ts +0 -173
- package/src/receipt.ts +0 -52
- package/src/tests/config.test.ts +0 -61
- package/src/tests/engine.test.ts +0 -47
- package/src/tests/receipt.test.ts +0 -44
- package/tsconfig.json +0 -16
|
Binary file
|
package/src/approval-server.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
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
|
-
}
|
package/src/approval-ui.ts
DELETED
|
@@ -1,154 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
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
|
-
}
|
package/src/pending.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
-
|
|
3
|
-
export interface PendingAction {
|
|
4
|
-
id: string;
|
|
5
|
-
timestamp: string;
|
|
6
|
-
tool_name: string;
|
|
7
|
-
tool_args: any;
|
|
8
|
-
agent_id: string;
|
|
9
|
-
rule_id: string;
|
|
10
|
-
status: 'pending' | 'approved' | 'denied';
|
|
11
|
-
/** The original JSON-RPC line to forward on approval */
|
|
12
|
-
originalLine: string;
|
|
13
|
-
/** The JSON-RPC request id for matching child responses */
|
|
14
|
-
jsonrpcId: string | number | null;
|
|
15
|
-
/** Resolver — call with the response line (or error JSON) to unblock the proxy */
|
|
16
|
-
resolve: (responseLine: string) => void;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const queue: Map<string, PendingAction> = new Map();
|
|
20
|
-
|
|
21
|
-
export function addPending(
|
|
22
|
-
toolName: string,
|
|
23
|
-
toolArgs: any,
|
|
24
|
-
agentId: string,
|
|
25
|
-
ruleId: string,
|
|
26
|
-
originalLine: string,
|
|
27
|
-
jsonrpcId: string | number | null,
|
|
28
|
-
resolve: (responseLine: string) => void,
|
|
29
|
-
): PendingAction {
|
|
30
|
-
const action: PendingAction = {
|
|
31
|
-
id: uuidv4(),
|
|
32
|
-
timestamp: new Date().toISOString(),
|
|
33
|
-
tool_name: toolName,
|
|
34
|
-
tool_args: toolArgs,
|
|
35
|
-
agent_id: agentId,
|
|
36
|
-
rule_id: ruleId,
|
|
37
|
-
status: 'pending',
|
|
38
|
-
originalLine,
|
|
39
|
-
jsonrpcId,
|
|
40
|
-
resolve,
|
|
41
|
-
};
|
|
42
|
-
queue.set(action.id, action);
|
|
43
|
-
return action;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function getPending(): PendingAction[] {
|
|
47
|
-
return Array.from(queue.values()).filter(a => a.status === 'pending');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function getAction(id: string): PendingAction | undefined {
|
|
51
|
-
return queue.get(id);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function resolveAction(id: string, status: 'approved' | 'denied'): PendingAction | undefined {
|
|
55
|
-
const action = queue.get(id);
|
|
56
|
-
if (!action || action.status !== 'pending') return undefined;
|
|
57
|
-
action.status = status;
|
|
58
|
-
return action;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function recentResolved(limit: number = 50): PendingAction[] {
|
|
62
|
-
return Array.from(queue.values())
|
|
63
|
-
.filter(a => a.status !== 'pending')
|
|
64
|
-
.slice(-limit);
|
|
65
|
-
}
|