mobilecoder-mcp 1.0.4 â 2.0.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/.security.log +5 -0
- package/dist/agent.d.ts +15 -8
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +161 -61
- package/dist/agent.js.map +1 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +35 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +24 -21
- package/dist/index.js.map +1 -1
- package/dist/mcp-handler.d.ts.map +1 -1
- package/dist/mcp-handler.js +46 -6
- package/dist/mcp-handler.js.map +1 -1
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +2 -5
- package/dist/security.js.map +1 -1
- package/dist/webrtc.d.ts +4 -10
- package/dist/webrtc.d.ts.map +1 -1
- package/dist/webrtc.js +96 -305
- package/dist/webrtc.js.map +1 -1
- package/package.json +23 -24
- package/src/agent.ts +178 -67
- package/src/cli.ts +35 -0
- package/src/types.d.ts +8 -0
- package/src/adapters/cli-adapter.ts +0 -73
- package/src/index.ts +0 -172
- package/src/mcp-handler.ts +0 -327
- package/src/security.ts +0 -345
- package/src/tool-detector.ts +0 -110
- package/src/webrtc.ts +0 -387
package/src/mcp-handler.ts
DELETED
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
-
import {
|
|
4
|
-
CallToolRequestSchema,
|
|
5
|
-
ListToolsRequestSchema,
|
|
6
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
-
import { WebRTCConnection } from './webrtc.js';
|
|
8
|
-
import {
|
|
9
|
-
validatePath,
|
|
10
|
-
validateFile,
|
|
11
|
-
validateCommand,
|
|
12
|
-
sanitizeInput,
|
|
13
|
-
sanitizePath,
|
|
14
|
-
safeResolvePath,
|
|
15
|
-
rateLimiters,
|
|
16
|
-
securityLogger,
|
|
17
|
-
generateSecureToken
|
|
18
|
-
} from './security.js';
|
|
19
|
-
import * as fs from 'fs';
|
|
20
|
-
import * as path from 'path';
|
|
21
|
-
import * as crypto from 'crypto';
|
|
22
|
-
|
|
23
|
-
// Queue to store commands received from mobile
|
|
24
|
-
const commandQueue: string[] = [];
|
|
25
|
-
|
|
26
|
-
export async function setupMCPServer(webrtc: WebRTCConnection): Promise<void> {
|
|
27
|
-
// Create MCP server
|
|
28
|
-
const server = new Server(
|
|
29
|
-
{
|
|
30
|
-
name: 'mobilecoder-mcp',
|
|
31
|
-
version: '1.0.0',
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
capabilities: {
|
|
35
|
-
tools: {},
|
|
36
|
-
},
|
|
37
|
-
}
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
// Set up error handling
|
|
41
|
-
server.onerror = (error: any) => {
|
|
42
|
-
console.error('[MCP Error]', error);
|
|
43
|
-
securityLogger.log('mcp_server_error', { error: error.message || 'Unknown error' }, 'medium');
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
// List available tools
|
|
47
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
48
|
-
return {
|
|
49
|
-
tools: [
|
|
50
|
-
{
|
|
51
|
-
name: 'get_next_command',
|
|
52
|
-
description: 'Get next pending command from mobile device',
|
|
53
|
-
inputSchema: {
|
|
54
|
-
type: 'object',
|
|
55
|
-
properties: {},
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
name: 'send_message',
|
|
60
|
-
description: 'Send a message or status update to mobile device',
|
|
61
|
-
inputSchema: {
|
|
62
|
-
type: 'object',
|
|
63
|
-
properties: {
|
|
64
|
-
message: {
|
|
65
|
-
type: 'string',
|
|
66
|
-
description: 'The message to send to user',
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
required: ['message'],
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
name: 'list_directory',
|
|
74
|
-
description: 'List files and directories in a path',
|
|
75
|
-
inputSchema: {
|
|
76
|
-
type: 'object',
|
|
77
|
-
properties: {
|
|
78
|
-
path: {
|
|
79
|
-
type: 'string',
|
|
80
|
-
description: 'The directory path to list (relative to cwd)',
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
name: 'read_file',
|
|
87
|
-
description: 'Read contents of a file',
|
|
88
|
-
inputSchema: {
|
|
89
|
-
type: 'object',
|
|
90
|
-
properties: {
|
|
91
|
-
path: {
|
|
92
|
-
type: 'string',
|
|
93
|
-
description: 'The file path to read',
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
required: ['path'],
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
],
|
|
100
|
-
};
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
// Handle tool calls from MCP (Claude/Cursor)
|
|
104
|
-
server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
|
|
105
|
-
const { name, arguments: args } = request.params;
|
|
106
|
-
|
|
107
|
-
if (name === 'get_next_command') {
|
|
108
|
-
const command = commandQueue.shift();
|
|
109
|
-
if (!command) {
|
|
110
|
-
return { content: [{ type: 'text', text: 'No pending commands.' }] };
|
|
111
|
-
}
|
|
112
|
-
return { content: [{ type: 'text', text: command }] };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (name === 'send_message') {
|
|
116
|
-
const message = (args as { message?: string })?.message;
|
|
117
|
-
if (!message) {
|
|
118
|
-
return { content: [{ type: 'text', text: 'Error: Message is required' }], isError: true };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Sanitize message content
|
|
122
|
-
const sanitizedMessage = sanitizeInput(message);
|
|
123
|
-
|
|
124
|
-
// Check if message contains diff data
|
|
125
|
-
if (typeof args === 'object' && (args as any).diff) {
|
|
126
|
-
webrtc.send({
|
|
127
|
-
type: 'result',
|
|
128
|
-
data: {
|
|
129
|
-
diff: (args as any).diff,
|
|
130
|
-
oldCode: (args as any).oldCode,
|
|
131
|
-
newCode: (args as any).newCode,
|
|
132
|
-
fileName: (args as any).fileName
|
|
133
|
-
},
|
|
134
|
-
timestamp: Date.now()
|
|
135
|
-
});
|
|
136
|
-
} else {
|
|
137
|
-
webrtc.send({ type: 'result', data: sanitizedMessage, timestamp: Date.now() });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return { content: [{ type: 'text', text: `Message sent to mobile: ${typeof args === 'object' ? 'Diff data' : sanitizedMessage}` }] };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (name === 'list_directory') {
|
|
144
|
-
try {
|
|
145
|
-
const requestId = generateSecureToken(16);
|
|
146
|
-
const fileList = await handleListDirectory(process.cwd(), args as any, requestId);
|
|
147
|
-
return { content: [{ type: 'text', text: JSON.stringify(fileList) }] };
|
|
148
|
-
} catch (error: any) {
|
|
149
|
-
return { content: [{ type: 'text', text: error.message }], isError: true };
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (name === 'read_file') {
|
|
154
|
-
try {
|
|
155
|
-
const requestId = generateSecureToken(16);
|
|
156
|
-
const content = await handleReadFile(process.cwd(), args as any, requestId);
|
|
157
|
-
return { content: [{ type: 'text', text: content }] };
|
|
158
|
-
} catch (error: any) {
|
|
159
|
-
return { content: [{ type: 'text', text: error.message }], isError: true };
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
// Connect WebRTC listeners
|
|
167
|
-
webrtc.onConnect(() => {
|
|
168
|
-
console.log('đą [MCP] Mobile device connected');
|
|
169
|
-
securityLogger.log('mobile_device_connected', { timestamp: Date.now() }, 'low');
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
webrtc.onMessage(async (message: any) => {
|
|
173
|
-
// Handle command queueing
|
|
174
|
-
if (message.type === 'command' && message.text) {
|
|
175
|
-
const sanitizedCommand = sanitizeInput(message.text);
|
|
176
|
-
|
|
177
|
-
// Rate limiting
|
|
178
|
-
if (!rateLimiters.commands.isAllowed('command')) {
|
|
179
|
-
securityLogger.logRateLimitExceeded('command', 'queue_command');
|
|
180
|
-
webrtc.send({
|
|
181
|
-
type: 'error',
|
|
182
|
-
data: 'Rate limit exceeded. Please try again later.',
|
|
183
|
-
timestamp: Date.now()
|
|
184
|
-
});
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Command validation
|
|
189
|
-
const commandValidation = validateCommand(sanitizedCommand);
|
|
190
|
-
if (!commandValidation.valid) {
|
|
191
|
-
securityLogger.logBlockedCommand(sanitizedCommand, commandValidation.error || 'Unknown reason');
|
|
192
|
-
webrtc.send({
|
|
193
|
-
type: 'error',
|
|
194
|
-
data: 'Command blocked for security reasons.',
|
|
195
|
-
timestamp: Date.now()
|
|
196
|
-
});
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
console.log(` [MCP] Queuing command: ${sanitizedCommand}`);
|
|
201
|
-
commandQueue.push(sanitizedCommand);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Handle direct tool calls from mobile (for File Explorer)
|
|
205
|
-
if (message.type === 'tool_call') {
|
|
206
|
-
const { tool, data, id } = message;
|
|
207
|
-
console.log(`đ ī¸ [MCP] Tool call received: ${tool}`, data);
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
let result;
|
|
211
|
-
if (tool === 'list_directory') {
|
|
212
|
-
result = await handleListDirectory(process.cwd(), data, id);
|
|
213
|
-
} else if (tool === 'read_file') {
|
|
214
|
-
result = await handleReadFile(process.cwd(), data, id);
|
|
215
|
-
} else {
|
|
216
|
-
throw new Error(`Unknown tool: ${tool}`);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
webrtc.send({
|
|
220
|
-
type: 'tool_result',
|
|
221
|
-
id: id, // Echo back ID for correlation
|
|
222
|
-
tool: tool,
|
|
223
|
-
data: result,
|
|
224
|
-
timestamp: Date.now()
|
|
225
|
-
});
|
|
226
|
-
} catch (error: any) {
|
|
227
|
-
console.error(`â [MCP] Tool execution failed: ${error.message}`);
|
|
228
|
-
securityLogger.log('tool_execution_failed', { tool, error: error.message }, 'medium');
|
|
229
|
-
webrtc.send({
|
|
230
|
-
type: 'tool_result',
|
|
231
|
-
id: id,
|
|
232
|
-
tool: tool,
|
|
233
|
-
error: error.message,
|
|
234
|
-
timestamp: Date.now()
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
// Start MCP server with stdio transport
|
|
241
|
-
const transport = new StdioServerTransport();
|
|
242
|
-
await server.connect(transport);
|
|
243
|
-
|
|
244
|
-
console.log('â
MCP Server initialized (stdio transport)');
|
|
245
|
-
securityLogger.log('mcp_server_started', { timestamp: Date.now() }, 'low');
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Helper functions for file system operations
|
|
249
|
-
async function handleListDirectory(cwd: string, args: { path?: string }, requestId?: string) {
|
|
250
|
-
const dirPath = args?.path || '.';
|
|
251
|
-
const sanitizedPath = sanitizePath(dirPath);
|
|
252
|
-
|
|
253
|
-
// Rate limiting
|
|
254
|
-
if (!rateLimiters.fileOperations.isAllowed(requestId || 'unknown')) {
|
|
255
|
-
securityLogger.logRateLimitExceeded(requestId || 'unknown', 'list_directory');
|
|
256
|
-
throw new Error('Rate limit exceeded for directory operations');
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Security validation
|
|
260
|
-
const pathValidation = validatePath(sanitizedPath, cwd);
|
|
261
|
-
if (!pathValidation.valid) {
|
|
262
|
-
securityLogger.logPathTraversal(sanitizedPath, path.resolve(cwd, sanitizedPath));
|
|
263
|
-
throw new Error(`Access denied: ${pathValidation.error}`);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
const resolvedPath = await safeResolvePath(cwd, sanitizedPath);
|
|
268
|
-
const stats = await fs.promises.stat(resolvedPath);
|
|
269
|
-
if (!stats.isDirectory()) {
|
|
270
|
-
throw new Error('Path is not a directory');
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const files = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
|
|
274
|
-
const fileList = files.map((f) => ({
|
|
275
|
-
name: f.name,
|
|
276
|
-
isDirectory: f.isDirectory(),
|
|
277
|
-
path: path.join(sanitizedPath, f.name).replace(/\\/g, '/'), // Normalize paths
|
|
278
|
-
}));
|
|
279
|
-
|
|
280
|
-
// Sort: directories first, then files
|
|
281
|
-
fileList.sort((a, b) => {
|
|
282
|
-
if (a.isDirectory === b.isDirectory) {
|
|
283
|
-
return a.name.localeCompare(b.name);
|
|
284
|
-
}
|
|
285
|
-
return a.isDirectory ? -1 : 1;
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
return fileList;
|
|
289
|
-
} catch (error: any) {
|
|
290
|
-
securityLogger.log('directory_list_error', { path: sanitizedPath, error: error.message }, 'medium');
|
|
291
|
-
throw new Error(`Error listing directory: ${error.message}`);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
async function handleReadFile(cwd: string, args: { path?: string }, requestId?: string) {
|
|
296
|
-
const filePath = args?.path;
|
|
297
|
-
if (!filePath) throw new Error('Path is required');
|
|
298
|
-
|
|
299
|
-
const sanitizedPath = sanitizePath(filePath);
|
|
300
|
-
|
|
301
|
-
// Rate limiting
|
|
302
|
-
if (!rateLimiters.fileOperations.isAllowed(requestId || 'unknown')) {
|
|
303
|
-
securityLogger.logRateLimitExceeded(requestId || 'unknown', 'read_file');
|
|
304
|
-
throw new Error('Rate limit exceeded for file operations');
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Security validation
|
|
308
|
-
const fileValidation = validateFile(sanitizedPath, cwd);
|
|
309
|
-
if (!fileValidation.valid) {
|
|
310
|
-
securityLogger.log('file_access_denied', { path: sanitizedPath, reason: fileValidation.error }, 'high');
|
|
311
|
-
throw new Error(`Access denied: ${fileValidation.error}`);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
try {
|
|
315
|
-
const resolvedPath = await safeResolvePath(cwd, sanitizedPath);
|
|
316
|
-
const stats = await fs.promises.stat(resolvedPath);
|
|
317
|
-
if (stats.isDirectory()) {
|
|
318
|
-
throw new Error('Path is a directory, not a file');
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
return await fs.promises.readFile(resolvedPath, 'utf-8');
|
|
322
|
-
} catch (error: any) {
|
|
323
|
-
securityLogger.log('file_read_error', { path: sanitizedPath, error: error.message }, 'medium');
|
|
324
|
-
throw new Error(`Error reading file: ${error.message}`);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
package/src/security.ts
DELETED
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
import * as crypto from 'crypto';
|
|
2
|
-
import * as fs from 'fs';
|
|
3
|
-
import * as path from 'path';
|
|
4
|
-
|
|
5
|
-
// Security configuration
|
|
6
|
-
export const SECURITY_CONFIG = {
|
|
7
|
-
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
8
|
-
maxRequestsPerMinute: 60,
|
|
9
|
-
maxRequestsPerHour: 1000,
|
|
10
|
-
allowedFileExtensions: ['.ts', '.js', '.jsx', '.tsx', '.json', '.md', '.txt', '.yml', '.yaml', '.env.example'],
|
|
11
|
-
blockedPaths: [
|
|
12
|
-
'.git',
|
|
13
|
-
'node_modules',
|
|
14
|
-
'.env',
|
|
15
|
-
'.env.local',
|
|
16
|
-
'.env.development',
|
|
17
|
-
'.env.production',
|
|
18
|
-
'dist',
|
|
19
|
-
'build',
|
|
20
|
-
'.next',
|
|
21
|
-
'.nuxt',
|
|
22
|
-
'.cache',
|
|
23
|
-
'tmp',
|
|
24
|
-
'temp'
|
|
25
|
-
],
|
|
26
|
-
blockedFilePatterns: [
|
|
27
|
-
/\.key$/,
|
|
28
|
-
/\.pem$/,
|
|
29
|
-
/\.crt$/,
|
|
30
|
-
/\.p12$/,
|
|
31
|
-
/private/i,
|
|
32
|
-
/secret/i,
|
|
33
|
-
/password/i,
|
|
34
|
-
/token/i,
|
|
35
|
-
/\.log$/,
|
|
36
|
-
/\.pid$/,
|
|
37
|
-
/\.lock$/,
|
|
38
|
-
]
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
// Rate limiting
|
|
42
|
-
class RateLimiter {
|
|
43
|
-
private requests = new Map<string, { count: number; resetTime: number; lastReset: number }>();
|
|
44
|
-
|
|
45
|
-
constructor(private maxRequests: number, private windowMs: number) { }
|
|
46
|
-
|
|
47
|
-
isAllowed(identifier: string): boolean {
|
|
48
|
-
const now = Date.now();
|
|
49
|
-
const entry = this.requests.get(identifier);
|
|
50
|
-
|
|
51
|
-
if (!entry) {
|
|
52
|
-
this.requests.set(identifier, {
|
|
53
|
-
count: 1,
|
|
54
|
-
resetTime: now + this.windowMs,
|
|
55
|
-
lastReset: now
|
|
56
|
-
});
|
|
57
|
-
return true;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Reset if window expired
|
|
61
|
-
if (now > entry.resetTime) {
|
|
62
|
-
entry.count = 1;
|
|
63
|
-
entry.resetTime = now + this.windowMs;
|
|
64
|
-
entry.lastReset = now;
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (entry.count >= this.maxRequests) {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
entry.count++;
|
|
73
|
-
return true;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
cleanup(): void {
|
|
77
|
-
const now = Date.now();
|
|
78
|
-
for (const [key, entry] of this.requests.entries()) {
|
|
79
|
-
if (now > entry.resetTime) {
|
|
80
|
-
this.requests.delete(key);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export const rateLimiters = {
|
|
87
|
-
perMinute: new RateLimiter(SECURITY_CONFIG.maxRequestsPerMinute, 60 * 1000),
|
|
88
|
-
perHour: new RateLimiter(SECURITY_CONFIG.maxRequestsPerHour, 60 * 60 * 1000),
|
|
89
|
-
fileOperations: new RateLimiter(30, 60 * 1000),
|
|
90
|
-
commands: new RateLimiter(10, 60 * 1000)
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
// Allowed commands for MCP agent
|
|
94
|
-
export const ALLOWED_COMMANDS = new Set([
|
|
95
|
-
'npm', 'yarn', 'pnpm', 'git', 'docker', 'docker-compose', 'claude-code', 'gemini', 'qoder',
|
|
96
|
-
'node', 'python', 'python3', 'pip', 'pipx', 'cargo', 'brew', 'echo', 'ls', 'pwd',
|
|
97
|
-
'cat', 'mkdir', 'touch', 'rmdir', 'mv', 'cp', 'test', 'grep', 'find'
|
|
98
|
-
]);
|
|
99
|
-
|
|
100
|
-
// Input validation
|
|
101
|
-
export async function safeResolvePath(base: string, userInput: string): Promise<string> {
|
|
102
|
-
const resolved = path.resolve(base, userInput);
|
|
103
|
-
|
|
104
|
-
// Follow symlinks and get the real absolute path
|
|
105
|
-
try {
|
|
106
|
-
const realPath = fs.realpathSync(resolved);
|
|
107
|
-
const realBase = fs.realpathSync(path.resolve(base));
|
|
108
|
-
|
|
109
|
-
if (!realPath.startsWith(realBase)) {
|
|
110
|
-
securityLogger.logPathTraversal(userInput, realPath);
|
|
111
|
-
throw new Error('Path traversal attempt detected');
|
|
112
|
-
}
|
|
113
|
-
return realPath;
|
|
114
|
-
} catch (err: any) {
|
|
115
|
-
if (err.code === 'ENOENT') {
|
|
116
|
-
// If path doesn't exist yet (e.g. creating a new file), just check the parent
|
|
117
|
-
const parentDir = path.dirname(resolved);
|
|
118
|
-
try {
|
|
119
|
-
const realParent = fs.realpathSync(parentDir);
|
|
120
|
-
const realBase = fs.realpathSync(path.resolve(base));
|
|
121
|
-
if (!realParent.startsWith(realBase)) {
|
|
122
|
-
throw new Error('Path traversal attempt detected (parent)');
|
|
123
|
-
}
|
|
124
|
-
return resolved;
|
|
125
|
-
} catch (parentErr) {
|
|
126
|
-
throw new Error(`Invalid path: ${err.message}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
throw err;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function validatePath(filePath: string, cwd: string): { valid: boolean; error?: string } {
|
|
134
|
-
// Normalize path
|
|
135
|
-
const normalizedPath = path.normalize(filePath);
|
|
136
|
-
|
|
137
|
-
// Check for blocked paths in components
|
|
138
|
-
const pathParts = normalizedPath.split(path.sep);
|
|
139
|
-
for (const part of pathParts) {
|
|
140
|
-
if (SECURITY_CONFIG.blockedPaths.includes(part)) {
|
|
141
|
-
return { valid: false, error: `Access to ${part} is not allowed` };
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Check for blocked file patterns
|
|
146
|
-
for (const pattern of SECURITY_CONFIG.blockedFilePatterns) {
|
|
147
|
-
if (pattern.test(normalizedPath)) {
|
|
148
|
-
return { valid: false, error: 'Access to sensitive files is not allowed' };
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return { valid: true };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function validateFile(filePath: string, cwd: string): { valid: boolean; error?: string } {
|
|
156
|
-
const pathValidation = validatePath(filePath, cwd);
|
|
157
|
-
if (!pathValidation.valid) {
|
|
158
|
-
return pathValidation;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Check file extension
|
|
162
|
-
const ext = path.extname(filePath).toLowerCase();
|
|
163
|
-
if (!ext || !SECURITY_CONFIG.allowedFileExtensions.includes(ext)) {
|
|
164
|
-
// Also allow files without extension if they are in the allowed list (like .gitignore)
|
|
165
|
-
const basename = path.basename(filePath);
|
|
166
|
-
if (!['.gitignore', '.mcprules', 'package.json', 'README.md'].includes(basename)) {
|
|
167
|
-
return { valid: false, error: `File type ${ext} or name ${basename} is not allowed` };
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Check file size if exists
|
|
172
|
-
const fullPath = path.resolve(cwd, filePath);
|
|
173
|
-
try {
|
|
174
|
-
const stats = fs.statSync(fullPath);
|
|
175
|
-
if (stats.size > SECURITY_CONFIG.maxFileSize) {
|
|
176
|
-
return { valid: false, error: 'File too large' };
|
|
177
|
-
}
|
|
178
|
-
} catch {
|
|
179
|
-
// File doesn't exist, that's ok
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return { valid: true };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function validateCommand(command: string): { valid: boolean; error?: string } {
|
|
186
|
-
const trimmedCmd = command.trim();
|
|
187
|
-
const firstWord = trimmedCmd.split(/\s+/)[0].toLowerCase();
|
|
188
|
-
|
|
189
|
-
// Check against allow-list
|
|
190
|
-
if (!ALLOWED_COMMANDS.has(firstWord)) {
|
|
191
|
-
return { valid: false, error: `Command '${firstWord}' is not in the allow-list` };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Check for command injection/chaining characters
|
|
195
|
-
const forbiddenChars = [';', '&', '|', '`', '$('];
|
|
196
|
-
for (const char of forbiddenChars) {
|
|
197
|
-
if (trimmedCmd.includes(char)) {
|
|
198
|
-
return { valid: false, error: `Forbidden character detected: ${char}` };
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Check for dangerous patterns as a second layer
|
|
203
|
-
const dangerousPatterns = [
|
|
204
|
-
/\brm\s+-rf\b/i,
|
|
205
|
-
/\bsudo\b/i,
|
|
206
|
-
/\bsu\s/i,
|
|
207
|
-
/\bchmod\s+777\b/i,
|
|
208
|
-
/\bwget\b|\bcurl\b/i,
|
|
209
|
-
/\bnc\s|\bnetcat\b/i,
|
|
210
|
-
/\bssh\b/i,
|
|
211
|
-
/\bscp\b/i,
|
|
212
|
-
/\brsync\b/i,
|
|
213
|
-
/\bdd\s+if=/i,
|
|
214
|
-
/\bmkfs\b/i,
|
|
215
|
-
/\bfdisk\b/i,
|
|
216
|
-
/\bmount\b/i,
|
|
217
|
-
/\bumount\b/i,
|
|
218
|
-
/\bpasswd\b/i,
|
|
219
|
-
/\bshadow\b/i,
|
|
220
|
-
/\bcrontab\b/i,
|
|
221
|
-
/\bsystemctl\b/i,
|
|
222
|
-
/\bservice\s/i,
|
|
223
|
-
/\bkill\s+-9\b/i,
|
|
224
|
-
/\bkillall\b/i,
|
|
225
|
-
/>\s*\/dev\/null/,
|
|
226
|
-
/>\s*\/dev\/(zero|random|urandom)/,
|
|
227
|
-
];
|
|
228
|
-
|
|
229
|
-
for (const pattern of dangerousPatterns) {
|
|
230
|
-
if (pattern.test(command)) {
|
|
231
|
-
return { valid: false, error: 'Dangerous command detected' };
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return { valid: true };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Sanitization
|
|
239
|
-
export function sanitizeInput(input: string): string {
|
|
240
|
-
return input
|
|
241
|
-
.replace(/[<>]/g, '') // Remove HTML tags
|
|
242
|
-
.replace(/[\x00-\x1f\x7f]/g, '') // Remove control characters
|
|
243
|
-
.replace(/[\r\n\t]/g, ' ') // Replace newlines and tabs
|
|
244
|
-
.trim()
|
|
245
|
-
.substring(0, 5000); // Increased limit slightly for large diffs
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
export function sanitizePath(input: string): string {
|
|
249
|
-
return input
|
|
250
|
-
.replace(/\.\./g, '') // Basic protection, safeResolvePath does the heavy lifting
|
|
251
|
-
.replace(/[<>:"|?*]/g, '') // Remove invalid characters
|
|
252
|
-
.trim();
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Security logging
|
|
256
|
-
export class SecurityLogger {
|
|
257
|
-
private static instance: SecurityLogger;
|
|
258
|
-
private logFile: string;
|
|
259
|
-
|
|
260
|
-
private constructor() {
|
|
261
|
-
this.logFile = path.join(process.cwd(), '.security.log');
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
static getInstance(): SecurityLogger {
|
|
265
|
-
if (!SecurityLogger.instance) {
|
|
266
|
-
SecurityLogger.instance = new SecurityLogger();
|
|
267
|
-
}
|
|
268
|
-
return SecurityLogger.instance;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
log(event: string, details: any, severity: 'low' | 'medium' | 'high' = 'medium'): void {
|
|
272
|
-
const logEntry = {
|
|
273
|
-
timestamp: new Date().toISOString(),
|
|
274
|
-
event,
|
|
275
|
-
details,
|
|
276
|
-
severity,
|
|
277
|
-
pid: process.pid,
|
|
278
|
-
user: process.env.USER || 'unknown'
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const logLine = JSON.stringify(logEntry) + '\n';
|
|
282
|
-
|
|
283
|
-
try {
|
|
284
|
-
fs.appendFileSync(this.logFile, logLine);
|
|
285
|
-
} catch (error) {
|
|
286
|
-
console.error('Failed to write security log:', error);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Also log to console for immediate visibility
|
|
290
|
-
if (severity === 'high') {
|
|
291
|
-
console.error('đ¨ SECURITY ALERT:', logEntry);
|
|
292
|
-
} else if (severity === 'medium') {
|
|
293
|
-
console.warn('â ī¸ SECURITY WARNING:', logEntry);
|
|
294
|
-
} else {
|
|
295
|
-
console.log('âšī¸ SECURITY INFO:', logEntry);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
logBlockedCommand(command: string, reason: string): void {
|
|
300
|
-
this.log('blocked_command', { command, reason }, 'high');
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
logPathTraversal(attemptedPath: string, resolvedPath: string): void {
|
|
304
|
-
this.log('path_traversal', { attemptedPath, resolvedPath }, 'high');
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
logRateLimitExceeded(identifier: string, operation: string): void {
|
|
308
|
-
this.log('rate_limit_exceeded', { identifier, operation }, 'medium');
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
logSuspiciousActivity(activity: string, details: any): void {
|
|
312
|
-
this.log('suspicious_activity', { activity, details }, 'medium');
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
export const securityLogger = SecurityLogger.getInstance();
|
|
317
|
-
|
|
318
|
-
// Cleanup old rate limit entries periodically
|
|
319
|
-
setInterval(() => {
|
|
320
|
-
rateLimiters.perMinute.cleanup();
|
|
321
|
-
rateLimiters.perHour.cleanup();
|
|
322
|
-
rateLimiters.fileOperations.cleanup();
|
|
323
|
-
rateLimiters.commands.cleanup();
|
|
324
|
-
}, 5 * 60 * 1000); // Every 5 minutes
|
|
325
|
-
|
|
326
|
-
// Generate secure tokens
|
|
327
|
-
export function generateSecureToken(length: number = 32): string {
|
|
328
|
-
return crypto.randomBytes(length).toString('hex');
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Validate session tokens
|
|
332
|
-
export function validateSessionToken(token: string): boolean {
|
|
333
|
-
// Basic token validation
|
|
334
|
-
if (!token || typeof token !== 'string') {
|
|
335
|
-
return false;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Check length
|
|
339
|
-
if (token.length < 16 || token.length > 128) {
|
|
340
|
-
return false;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Check format (hex)
|
|
344
|
-
return /^[a-f0-9]+$/.test(token);
|
|
345
|
-
}
|