@jrooig/mcpshield 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.
@@ -0,0 +1,230 @@
1
+ import { exec } from 'node:child_process';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as http from 'node:http';
4
+ import * as os from 'node:os';
5
+ import * as path from 'node:path';
6
+ import process from 'node:process';
7
+ // NOTE: The spec names the session field "apiKey", but apiClient.ts was
8
+ // established in Step 1 with "token". We use "token" throughout for
9
+ // consistency with the existing validated schema.
10
+ const SESSION_DIR = path.join(os.homedir(), '.mcp-shield');
11
+ const SESSION_PATH = path.join(SESSION_DIR, 'session.json');
12
+ // El portal SSO vive en el MISMO servidor que la API (endpoint login-mock), así
13
+ // que su URL se deriva de MCP_SHIELD_SERVER_URL: un solo host que configurar.
14
+ // MCP_SHIELD_AUTH_URL sigue disponible como override explícito.
15
+ const SERVER_URL = (process.env['MCP_SHIELD_SERVER_URL'] ?? 'https://mcp-shield-server.onrender.com').replace(/\/+$/, '');
16
+ const AUTH_URL = process.env['MCP_SHIELD_AUTH_URL'] ?? `${SERVER_URL}/api/v1/auth/login-mock`;
17
+ const CLIENT_ID = 'mcp_shield_cli';
18
+ const LOOPBACK_START = 3009;
19
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1_000;
20
+ // ---------------------------------------------------------------------------
21
+ // HTML success page
22
+ // ---------------------------------------------------------------------------
23
+ const SUCCESS_HTML = `<!DOCTYPE html>
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="UTF-8">
27
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
28
+ <title>MCP-Shield — Authenticated</title>
29
+ <style>
30
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
31
+ body {
32
+ font-family: system-ui, -apple-system, sans-serif;
33
+ min-height: 100vh;
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: center;
37
+ background: #0a0a0a;
38
+ color: #e5e5e5;
39
+ }
40
+ .card {
41
+ text-align: center;
42
+ padding: 3rem 4rem;
43
+ max-width: 520px;
44
+ background: #141414;
45
+ border: 1px solid #2a2a2a;
46
+ border-radius: 16px;
47
+ box-shadow: 0 24px 64px rgba(0, 0, 0, .6);
48
+ }
49
+ .shield { font-size: 3.5rem; margin-bottom: 1.5rem; }
50
+ h1 { font-size: 1.75rem; font-weight: 600; color: #22c55e; margin-bottom: .75rem; }
51
+ p { color: #a3a3a3; line-height: 1.65; font-size: .95rem; }
52
+ .badge {
53
+ display: inline-block;
54
+ margin-top: 1.75rem;
55
+ padding: .35rem 1rem;
56
+ background: #0f2a1a;
57
+ border: 1px solid #166534;
58
+ border-radius: 20px;
59
+ color: #86efac;
60
+ font-family: ui-monospace, monospace;
61
+ font-size: .8rem;
62
+ letter-spacing: .02em;
63
+ }
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <div class="card">
68
+ <div class="shield">🛡️</div>
69
+ <h1>Authentication successful!</h1>
70
+ <p>Your Enterprise session has been saved.<br>You can close this tab and return to your terminal.</p>
71
+ <div class="badge">mcp-shield enterprise &middot; active</div>
72
+ </div>
73
+ </body>
74
+ </html>`;
75
+ // ---------------------------------------------------------------------------
76
+ // Helpers
77
+ // ---------------------------------------------------------------------------
78
+ function openBrowser(url) {
79
+ let cmd;
80
+ if (process.platform === 'darwin') {
81
+ cmd = `open ${JSON.stringify(url)}`;
82
+ }
83
+ else if (process.platform === 'win32') {
84
+ // Escape & for Windows cmd.exe so it doesn't split the URL into two arguments.
85
+ cmd = `cmd.exe /c start "" ${JSON.stringify(url.replace(/&/g, '^&'))}`;
86
+ }
87
+ else {
88
+ // Linux / WSL: try xdg-open; wslview is preferred in WSL if xdg-open is absent.
89
+ cmd = `xdg-open ${JSON.stringify(url)} 2>/dev/null || wslview ${JSON.stringify(url)}`;
90
+ }
91
+ exec(cmd, (err) => {
92
+ if (err) {
93
+ process.stderr.write(`[mcp-shield] Browser could not be opened automatically.\n` +
94
+ `[mcp-shield] Please visit this URL to complete login:\n ${url}\n`);
95
+ }
96
+ });
97
+ }
98
+ async function findFreePort(start) {
99
+ for (let port = start; port <= start + 20; port++) {
100
+ const available = await new Promise((resolve) => {
101
+ const probe = http.createServer();
102
+ probe.once('error', () => resolve(false));
103
+ probe.listen(port, '127.0.0.1', () => probe.close(() => resolve(true)));
104
+ });
105
+ if (available)
106
+ return port;
107
+ }
108
+ return start; // last resort — the server will surface a clear bind error
109
+ }
110
+ async function saveSession(data) {
111
+ await fs.mkdir(SESSION_DIR, { recursive: true });
112
+ await fs.writeFile(SESSION_PATH, JSON.stringify(data, null, 2), 'utf-8');
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // Public API
116
+ // ---------------------------------------------------------------------------
117
+ /** Returns true when ~/.mcp-shield/session.json exists and contains a valid token. */
118
+ export async function hasSession() {
119
+ try {
120
+ const raw = await fs.readFile(SESSION_PATH, 'utf-8');
121
+ const parsed = JSON.parse(raw);
122
+ if (typeof parsed !== 'object' || parsed === null)
123
+ return false;
124
+ const obj = parsed;
125
+ const token = obj['token'];
126
+ return typeof token === 'string' && token.length > 0;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ /**
133
+ * Runs the OAuth2 loopback login flow:
134
+ * 1. Spins up a temporary HTTP server on localhost:3009 (or next free port).
135
+ * 2. Opens the Enterprise auth portal in the default browser.
136
+ * 3. Waits for the SSO redirect to deliver the token on /callback.
137
+ * 4. Saves the session to ~/.mcp-shield/session.json and closes the server.
138
+ *
139
+ * Rejects on timeout (5 min), missing token, or session-write failure.
140
+ */
141
+ export async function runCliLoginFlow() {
142
+ const port = await findFreePort(LOOPBACK_START);
143
+ const callbackUrl = `http://localhost:${port}/callback`;
144
+ const loginUrl = `${AUTH_URL}?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(callbackUrl)}`;
145
+ process.stderr.write(`[mcp-shield] Starting Enterprise login...\n`);
146
+ process.stderr.write(`[mcp-shield] If the browser does not open, visit:\n ${loginUrl}\n`);
147
+ await new Promise((resolve, reject) => {
148
+ let settled = false;
149
+ const timeoutHandle = setTimeout(() => {
150
+ if (settled)
151
+ return;
152
+ settled = true;
153
+ server.closeAllConnections();
154
+ server.close(() => reject(new Error('Login timed out after 5 minutes — no browser response received')));
155
+ }, LOGIN_TIMEOUT_MS);
156
+ // Allow the process to exit if something external kills it before the timeout.
157
+ timeoutHandle.unref();
158
+ const server = http.createServer((req, res) => {
159
+ const reqUrl = req.url ?? '';
160
+ if (!reqUrl.startsWith('/callback')) {
161
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
162
+ res.end('Not found');
163
+ return;
164
+ }
165
+ const parsedUrl = new URL(reqUrl, `http://localhost:${port}`);
166
+ const token = parsedUrl.searchParams.get('token');
167
+ const email = parsedUrl.searchParams.get('email') ?? '';
168
+ const errorParam = parsedUrl.searchParams.get('error');
169
+ // El servidor devuelve ?error=... cuando el login no puede completarse
170
+ // (p. ej. no existe un developer con ese email). Fallamos al instante con
171
+ // el mensaje en vez de esperar el timeout de 5 minutos.
172
+ if (errorParam) {
173
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
174
+ res.end(`Login failed: ${errorParam}`);
175
+ if (!settled) {
176
+ settled = true;
177
+ clearTimeout(timeoutHandle);
178
+ server.closeAllConnections();
179
+ server.close(() => reject(new Error(`Login failed: ${errorParam}`)));
180
+ }
181
+ return;
182
+ }
183
+ if (!token) {
184
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
185
+ res.end('Authentication error: missing token in callback URL.');
186
+ if (!settled) {
187
+ settled = true;
188
+ clearTimeout(timeoutHandle);
189
+ server.closeAllConnections();
190
+ server.close(() => reject(new Error('SSO callback did not include a token')));
191
+ }
192
+ return;
193
+ }
194
+ // Respond with the success page before closing so the browser renders it.
195
+ res.writeHead(200, {
196
+ 'Content-Type': 'text/html; charset=utf-8',
197
+ // Tell the browser not to reuse this connection so server.close() drains
198
+ // immediately after closeAllConnections().
199
+ 'Connection': 'close',
200
+ });
201
+ res.end(SUCCESS_HTML, () => {
202
+ // Response body flushed to OS socket buffer — now tear down.
203
+ if (settled)
204
+ return;
205
+ settled = true;
206
+ clearTimeout(timeoutHandle);
207
+ server.closeAllConnections();
208
+ server.close(() => {
209
+ saveSession({ token, email })
210
+ .then(() => {
211
+ process.stderr.write(`[mcp-shield] Logged in${email ? ` as ${email}` : ''}. Session saved.\n`);
212
+ resolve();
213
+ })
214
+ .catch(reject);
215
+ });
216
+ });
217
+ });
218
+ server.on('error', (err) => {
219
+ if (!settled) {
220
+ settled = true;
221
+ clearTimeout(timeoutHandle);
222
+ reject(err);
223
+ }
224
+ });
225
+ server.listen(port, '127.0.0.1', () => {
226
+ openBrowser(loginUrl);
227
+ });
228
+ });
229
+ }
230
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,16 @@
1
+ import { type SecurityRule } from '../config/rules.js';
2
+ export declare class RuleSyncManager {
3
+ private readonly onRulesUpdated;
4
+ private timer;
5
+ constructor(onRulesUpdated: (rules: SecurityRule[]) => void);
6
+ syncRules(): Promise<void>;
7
+ startPolling(): void;
8
+ stopPolling(): void;
9
+ }
10
+ /**
11
+ * Best-effort Enterprise initialization: attempts an initial sync and starts
12
+ * background polling. Returns the manager on success or null if the developer
13
+ * has no active Enterprise session — the proxy continues with cached/default
14
+ * rules either way.
15
+ */
16
+ export declare function tryStartEnterpriseSync(onRulesUpdated: (rules: SecurityRule[]) => void): Promise<RuleSyncManager | null>;
@@ -0,0 +1,81 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import process from 'node:process';
4
+ import { CACHE_PATH, isSecurityRule } from '../config/rules.js';
5
+ import { getApiClient, PaymentRequiredError } from './apiClient.js';
6
+ const POLL_INTERVAL_MS = 5 * 60 * 1_000;
7
+ function isSyncResponse(v) {
8
+ if (typeof v !== 'object' || v === null)
9
+ return false;
10
+ const obj = v;
11
+ const version = obj['version'];
12
+ const rules = obj['rules'];
13
+ if (typeof version !== 'string')
14
+ return false;
15
+ if (!Array.isArray(rules) || !rules.every(isSecurityRule))
16
+ return false;
17
+ return true;
18
+ }
19
+ async function saveCache(data) {
20
+ await fs.mkdir(path.dirname(CACHE_PATH), { recursive: true });
21
+ await fs.writeFile(CACHE_PATH, JSON.stringify(data, null, 2), 'utf-8');
22
+ }
23
+ export class RuleSyncManager {
24
+ onRulesUpdated;
25
+ timer = null;
26
+ constructor(onRulesUpdated) {
27
+ this.onRulesUpdated = onRulesUpdated;
28
+ }
29
+ async syncRules() {
30
+ const client = await getApiClient();
31
+ const raw = await client.get('/api/v1/rules/sync');
32
+ if (!isSyncResponse(raw)) {
33
+ throw new Error('MCP-Shield Enterprise: server returned an invalid rule set — ignoring update');
34
+ }
35
+ await saveCache(raw);
36
+ this.onRulesUpdated(raw.rules);
37
+ }
38
+ startPolling() {
39
+ if (this.timer !== null)
40
+ return;
41
+ this.timer = setInterval(() => {
42
+ this.syncRules().catch((err) => {
43
+ process.stderr.write(`[mcp-shield] enterprise rule sync failed: ${err instanceof Error ? err.message : String(err)}\n`);
44
+ });
45
+ }, POLL_INTERVAL_MS);
46
+ // Must not keep the process alive on its own — the proxy exits when the MCP
47
+ // client closes the pipe, not when the polling timer fires.
48
+ this.timer.unref();
49
+ }
50
+ stopPolling() {
51
+ if (this.timer !== null) {
52
+ clearInterval(this.timer);
53
+ this.timer = null;
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * Best-effort Enterprise initialization: attempts an initial sync and starts
59
+ * background polling. Returns the manager on success or null if the developer
60
+ * has no active Enterprise session — the proxy continues with cached/default
61
+ * rules either way.
62
+ */
63
+ export async function tryStartEnterpriseSync(onRulesUpdated) {
64
+ const manager = new RuleSyncManager(onRulesUpdated);
65
+ try {
66
+ await manager.syncRules();
67
+ manager.startPolling();
68
+ return manager;
69
+ }
70
+ catch (err) {
71
+ if (err instanceof PaymentRequiredError) {
72
+ process.stderr.write(`[mcp-shield] Enterprise features are OFF — no active subscription or the trial has expired. ` +
73
+ `Subscribe in the admin console to enable centralized rules and audit. Running with local rules.\n`);
74
+ }
75
+ else {
76
+ process.stderr.write(`[mcp-shield] enterprise sync unavailable: ${err instanceof Error ? err.message : String(err)}\n`);
77
+ }
78
+ return null;
79
+ }
80
+ }
81
+ //# sourceMappingURL=ruleSync.js.map
@@ -0,0 +1,36 @@
1
+ /** What callers supply — manager adds timestamp and session id. */
2
+ export interface TelemetryEventInput {
3
+ tool: string;
4
+ arguments?: Record<string, unknown>;
5
+ verdict: 'allowed' | 'blocked' | 'modified';
6
+ triggered_rule_name?: string;
7
+ sanitized_output_detected: boolean;
8
+ }
9
+ export declare class TelemetryManager {
10
+ private readonly email;
11
+ private readonly buffer;
12
+ private timer;
13
+ private flushing;
14
+ private warnedPaymentRequired;
15
+ private constructor();
16
+ static create(): Promise<TelemetryManager>;
17
+ /** Append an event to the in-memory buffer. Triggers an immediate flush if the buffer is full. */
18
+ record(input: TelemetryEventInput): void;
19
+ startFlushTimer(): void;
20
+ stopFlushTimer(): void;
21
+ /**
22
+ * Drains the in-memory buffer and ships it (plus any offline-queued events) to
23
+ * the audit endpoint. On network/server failure the buffer contents are
24
+ * appended to the local queue file so no audit record is ever lost.
25
+ *
26
+ * Only one flush runs at a time: concurrent calls (timer + buffer-full trigger)
27
+ * are no-ops until the current flush settles.
28
+ */
29
+ flush(): Promise<void>;
30
+ }
31
+ /**
32
+ * Tries to initialize the telemetry manager and start the flush timer. Returns
33
+ * null (and logs to stderr) if the developer has no active Enterprise session —
34
+ * the proxy runs without telemetry rather than failing to start.
35
+ */
36
+ export declare function tryStartEnterpriseTelemetry(): Promise<TelemetryManager | null>;
@@ -0,0 +1,136 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import process from 'node:process';
5
+ import { getApiClient, PaymentRequiredError } from './apiClient.js';
6
+ const QUEUE_PATH = path.join(os.homedir(), '.mcp-shield', 'telemetry.queue.json');
7
+ const FLUSH_INTERVAL_MS = 10_000;
8
+ const FLUSH_SIZE = 50;
9
+ // Unique per process startup — generated once when this module is first imported.
10
+ const SESSION_ID = `sess_${Math.random().toString(36).slice(2, 8)}`;
11
+ // ---------------------------------------------------------------------------
12
+ // Offline queue helpers
13
+ // ---------------------------------------------------------------------------
14
+ async function loadQueue() {
15
+ try {
16
+ const raw = await fs.readFile(QUEUE_PATH, 'utf-8');
17
+ const parsed = JSON.parse(raw);
18
+ return Array.isArray(parsed) ? parsed : [];
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ }
24
+ async function saveQueue(events) {
25
+ await fs.mkdir(path.dirname(QUEUE_PATH), { recursive: true });
26
+ await fs.writeFile(QUEUE_PATH, JSON.stringify(events), 'utf-8');
27
+ }
28
+ async function clearQueue() {
29
+ await fs.unlink(QUEUE_PATH).catch(() => { });
30
+ }
31
+ async function appendToQueue(events) {
32
+ const existing = await loadQueue();
33
+ await saveQueue([...existing, ...events]);
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // TelemetryManager
37
+ // ---------------------------------------------------------------------------
38
+ export class TelemetryManager {
39
+ email;
40
+ buffer = [];
41
+ timer = null;
42
+ flushing = false;
43
+ warnedPaymentRequired = false;
44
+ constructor(email) {
45
+ this.email = email;
46
+ }
47
+ static async create() {
48
+ const client = await getApiClient();
49
+ return new TelemetryManager(client.email ?? 'unknown');
50
+ }
51
+ /** Append an event to the in-memory buffer. Triggers an immediate flush if the buffer is full. */
52
+ record(input) {
53
+ this.buffer.push({ ...input, timestamp: Date.now(), client_session_id: SESSION_ID });
54
+ if (this.buffer.length >= FLUSH_SIZE) {
55
+ void this.flush();
56
+ }
57
+ }
58
+ startFlushTimer() {
59
+ if (this.timer !== null)
60
+ return;
61
+ this.timer = setInterval(() => { void this.flush(); }, FLUSH_INTERVAL_MS);
62
+ // Must not prevent clean process exit — the proxy exits when the MCP client
63
+ // closes the pipe, not when the telemetry timer fires.
64
+ this.timer.unref();
65
+ }
66
+ stopFlushTimer() {
67
+ if (this.timer !== null) {
68
+ clearInterval(this.timer);
69
+ this.timer = null;
70
+ }
71
+ }
72
+ /**
73
+ * Drains the in-memory buffer and ships it (plus any offline-queued events) to
74
+ * the audit endpoint. On network/server failure the buffer contents are
75
+ * appended to the local queue file so no audit record is ever lost.
76
+ *
77
+ * Only one flush runs at a time: concurrent calls (timer + buffer-full trigger)
78
+ * are no-ops until the current flush settles.
79
+ */
80
+ async flush() {
81
+ if (this.flushing)
82
+ return;
83
+ this.flushing = true;
84
+ // Drain atomically — new events accumulate in the buffer while we do I/O.
85
+ const buffered = this.buffer.splice(0);
86
+ try {
87
+ // Retry any events that failed to ship in a previous cycle (offline events
88
+ // come first to maintain chronological order in the audit log).
89
+ const queued = await loadQueue();
90
+ const allEvents = [...queued, ...buffered];
91
+ if (allEvents.length === 0)
92
+ return;
93
+ const client = await getApiClient();
94
+ await client.post('/api/v1/audit/logs', {
95
+ developer_email: this.email,
96
+ events: allEvents,
97
+ });
98
+ // All events shipped: the queue file is now obsolete.
99
+ await clearQueue();
100
+ }
101
+ catch (err) {
102
+ if (err instanceof PaymentRequiredError && !this.warnedPaymentRequired) {
103
+ this.warnedPaymentRequired = true;
104
+ process.stderr.write(`[mcp-shield] Enterprise audit is OFF — no active subscription or the trial has expired.\n`);
105
+ }
106
+ // Network/auth failure — persist what we just drained so it survives the
107
+ // next flush attempt (old queue events remain on disk untouched).
108
+ if (buffered.length > 0) {
109
+ await appendToQueue(buffered).catch(() => { });
110
+ }
111
+ }
112
+ finally {
113
+ this.flushing = false;
114
+ }
115
+ }
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // Best-effort startup helper
119
+ // ---------------------------------------------------------------------------
120
+ /**
121
+ * Tries to initialize the telemetry manager and start the flush timer. Returns
122
+ * null (and logs to stderr) if the developer has no active Enterprise session —
123
+ * the proxy runs without telemetry rather than failing to start.
124
+ */
125
+ export async function tryStartEnterpriseTelemetry() {
126
+ try {
127
+ const manager = await TelemetryManager.create();
128
+ manager.startFlushTimer();
129
+ return manager;
130
+ }
131
+ catch (err) {
132
+ process.stderr.write(`[mcp-shield] enterprise telemetry unavailable: ${err instanceof Error ? err.message : String(err)}\n`);
133
+ return null;
134
+ }
135
+ }
136
+ //# sourceMappingURL=telemetry.js.map
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP-Shield — entry point.
4
+ *
5
+ * Transparent security proxy between an MCP client (e.g. Claude Code) and a
6
+ * target MCP server. The client speaks line-delimited JSON-RPC 2.0 over stdio;
7
+ * MCP-Shield launches the real server as a child process and sits inline,
8
+ * relaying every packet through its security hooks:
9
+ *
10
+ * client (process.stdin) ──▶ [firewall + approval hooks] ──▶ target.stdin
11
+ * target.stdout ──▶ [sanitizer hook] ──▶ client (process.stdout)
12
+ *
13
+ * process.stdout carries protocol traffic ONLY — every diagnostic goes to
14
+ * stderr, otherwise the client would try to parse it as JSON-RPC.
15
+ */
16
+ export {};