apple-tools-mcp 1.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/lib/shell.js ADDED
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Safe shell execution utilities for apple-tools-mcp
3
+ *
4
+ * This module provides secure wrappers around child_process functions
5
+ * to prevent command injection vulnerabilities by:
6
+ * - Using spawnSync instead of execSync (avoids shell interpolation)
7
+ * - Passing arguments as arrays, not concatenated strings
8
+ * - Validating inputs before execution
9
+ */
10
+
11
+ import { spawnSync } from "child_process";
12
+ import path from "path";
13
+ import { escapeSQL } from "./validators.js";
14
+
15
+ // ============ SQLITE3 EXECUTION ============
16
+
17
+ /**
18
+ * Safely execute a sqlite3 query using spawnSync
19
+ * Prevents command injection by passing args as array
20
+ *
21
+ * @param {string} dbPath - Path to the SQLite database
22
+ * @param {string} query - SQL query to execute
23
+ * @param {object} options - Additional options
24
+ * @param {boolean} options.json - Return JSON output (default: true)
25
+ * @param {number} options.timeout - Timeout in ms (default: 30000)
26
+ * @param {number} options.maxBuffer - Max buffer size (default: 50MB)
27
+ * @returns {string} Raw output from sqlite3
28
+ * @throws {Error} If execution fails
29
+ */
30
+ export function safeSqlite3(dbPath, query, options = {}) {
31
+ const {
32
+ json = true,
33
+ timeout = 30000,
34
+ maxBuffer = 50 * 1024 * 1024
35
+ } = options;
36
+
37
+ // Validate database path exists and is absolute
38
+ if (!dbPath || typeof dbPath !== 'string') {
39
+ throw new Error('Database path is required');
40
+ }
41
+ if (!path.isAbsolute(dbPath)) {
42
+ throw new Error('Database path must be absolute');
43
+ }
44
+
45
+ // Validate query
46
+ if (!query || typeof query !== 'string') {
47
+ throw new Error('SQL query is required');
48
+ }
49
+
50
+ // Build args array - no shell interpolation possible
51
+ const args = [];
52
+ if (json) {
53
+ args.push('-json');
54
+ }
55
+ args.push(dbPath);
56
+ args.push(query.replace(/\n/g, ' ')); // Normalize whitespace
57
+
58
+ const result = spawnSync('sqlite3', args, {
59
+ encoding: 'utf-8',
60
+ timeout,
61
+ maxBuffer,
62
+ // Don't use shell - prevents injection
63
+ shell: false
64
+ });
65
+
66
+ if (result.error) {
67
+ throw result.error;
68
+ }
69
+
70
+ if (result.status !== 0) {
71
+ const errorMsg = result.stderr || `sqlite3 exited with code ${result.status}`;
72
+ throw new Error(errorMsg);
73
+ }
74
+
75
+ return result.stdout;
76
+ }
77
+
78
+ /**
79
+ * Execute sqlite3 and parse JSON result
80
+ *
81
+ * @param {string} dbPath - Path to the SQLite database
82
+ * @param {string} query - SQL query to execute
83
+ * @param {object} options - Additional options
84
+ * @returns {Array} Parsed JSON array of results
85
+ */
86
+ export function safeSqlite3Json(dbPath, query, options = {}) {
87
+ const output = safeSqlite3(dbPath, query, { ...options, json: true });
88
+
89
+ if (!output || output.trim() === '') {
90
+ return [];
91
+ }
92
+
93
+ try {
94
+ return JSON.parse(output);
95
+ } catch (e) {
96
+ console.error('Failed to parse sqlite3 JSON output:', e.message);
97
+ return [];
98
+ }
99
+ }
100
+
101
+ // ============ OSASCRIPT EXECUTION ============
102
+
103
+ /**
104
+ * Safely execute AppleScript using spawnSync
105
+ * Uses stdin to pass script content, preventing shell injection
106
+ *
107
+ * @param {string} script - AppleScript to execute
108
+ * @param {object} options - Additional options
109
+ * @param {number} options.timeout - Timeout in ms (default: 30000)
110
+ * @returns {string} Output from AppleScript
111
+ * @throws {Error} If execution fails
112
+ */
113
+ export function safeOsascript(script, options = {}) {
114
+ const {
115
+ timeout = 30000
116
+ } = options;
117
+
118
+ if (!script || typeof script !== 'string') {
119
+ throw new Error('AppleScript is required');
120
+ }
121
+
122
+ // Use -e flag with the script content passed as argument
123
+ // This is safer than heredoc shell syntax
124
+ const result = spawnSync('osascript', ['-e', script], {
125
+ encoding: 'utf-8',
126
+ timeout,
127
+ shell: false
128
+ });
129
+
130
+ if (result.error) {
131
+ throw result.error;
132
+ }
133
+
134
+ // osascript may return non-zero for certain operations
135
+ // Return stdout if we have it, otherwise throw
136
+ if (result.status !== 0 && !result.stdout) {
137
+ const errorMsg = result.stderr || `osascript exited with code ${result.status}`;
138
+ throw new Error(errorMsg);
139
+ }
140
+
141
+ return result.stdout;
142
+ }
143
+
144
+ // ============ MDFIND EXECUTION ============
145
+
146
+ /**
147
+ * Safely execute mdfind (Spotlight search) using spawnSync
148
+ *
149
+ * @param {string} query - Spotlight query
150
+ * @param {object} options - Additional options
151
+ * @param {string} options.onlyin - Directory to search in
152
+ * @param {number} options.timeout - Timeout in ms (default: 60000)
153
+ * @returns {string[]} Array of file paths
154
+ */
155
+ export function safeMdfind(query, options = {}) {
156
+ const {
157
+ onlyin,
158
+ timeout = 60000
159
+ } = options;
160
+
161
+ if (!query || typeof query !== 'string') {
162
+ throw new Error('Search query is required');
163
+ }
164
+
165
+ const args = [];
166
+
167
+ if (onlyin) {
168
+ if (!path.isAbsolute(onlyin)) {
169
+ throw new Error('onlyin path must be absolute');
170
+ }
171
+ args.push('-onlyin', onlyin);
172
+ }
173
+
174
+ args.push(query);
175
+
176
+ const result = spawnSync('mdfind', args, {
177
+ encoding: 'utf-8',
178
+ timeout,
179
+ maxBuffer: 100 * 1024 * 1024, // 100MB for large result sets
180
+ shell: false
181
+ });
182
+
183
+ if (result.error) {
184
+ throw result.error;
185
+ }
186
+
187
+ if (result.status !== 0) {
188
+ const errorMsg = result.stderr || `mdfind exited with code ${result.status}`;
189
+ throw new Error(errorMsg);
190
+ }
191
+
192
+ // Split output into lines, filter empty
193
+ return result.stdout
194
+ .split('\n')
195
+ .map(line => line.trim())
196
+ .filter(line => line.length > 0);
197
+ }
198
+
199
+ // ============ FIND EXECUTION ============
200
+
201
+ /**
202
+ * Safely execute find command using spawnSync
203
+ *
204
+ * @param {string} searchPath - Directory to search
205
+ * @param {object} options - Find options
206
+ * @param {string} options.name - Filename pattern (-name)
207
+ * @param {string} options.type - File type (-type f, d, etc.)
208
+ * @param {string} options.mtime - Modification time (-mtime)
209
+ * @param {number} options.maxdepth - Max directory depth
210
+ * @param {number} options.timeout - Timeout in ms (default: 120000)
211
+ * @returns {string[]} Array of file paths
212
+ */
213
+ export function safeFind(searchPath, options = {}) {
214
+ const {
215
+ name,
216
+ type,
217
+ mtime,
218
+ maxdepth,
219
+ timeout = 120000
220
+ } = options;
221
+
222
+ if (!searchPath || typeof searchPath !== 'string') {
223
+ throw new Error('Search path is required');
224
+ }
225
+ if (!path.isAbsolute(searchPath)) {
226
+ throw new Error('Search path must be absolute');
227
+ }
228
+
229
+ const args = [searchPath];
230
+
231
+ if (maxdepth !== undefined) {
232
+ const depth = parseInt(maxdepth);
233
+ if (!Number.isInteger(depth) || depth < 0) {
234
+ throw new Error('maxdepth must be a non-negative integer');
235
+ }
236
+ args.push('-maxdepth', String(depth));
237
+ }
238
+
239
+ if (type) {
240
+ // Validate type is a single character
241
+ if (!/^[fdlbcps]$/.test(type)) {
242
+ throw new Error('Invalid find type');
243
+ }
244
+ args.push('-type', type);
245
+ }
246
+
247
+ if (name) {
248
+ args.push('-name', name);
249
+ }
250
+
251
+ if (mtime) {
252
+ // Validate mtime format (e.g., -1, +7, 0)
253
+ if (!/^[+-]?\d+$/.test(mtime)) {
254
+ throw new Error('Invalid mtime format');
255
+ }
256
+ args.push('-mtime', mtime);
257
+ }
258
+
259
+ const result = spawnSync('find', args, {
260
+ encoding: 'utf-8',
261
+ timeout,
262
+ maxBuffer: 100 * 1024 * 1024,
263
+ shell: false
264
+ });
265
+
266
+ if (result.error) {
267
+ throw result.error;
268
+ }
269
+
270
+ // find may return non-zero if some paths are inaccessible
271
+ // We still want to return whatever paths it found
272
+ return result.stdout
273
+ .split('\n')
274
+ .map(line => line.trim())
275
+ .filter(line => line.length > 0);
276
+ }
277
+
278
+ // ============ GENERIC SAFE SPAWN ============
279
+
280
+ /**
281
+ * Generic safe spawn wrapper for other commands
282
+ *
283
+ * @param {string} command - Command to execute
284
+ * @param {string[]} args - Arguments as array
285
+ * @param {object} options - spawnSync options
286
+ * @returns {object} { stdout, stderr, status }
287
+ */
288
+ export function safeSpawn(command, args = [], options = {}) {
289
+ const {
290
+ timeout = 30000,
291
+ maxBuffer = 10 * 1024 * 1024,
292
+ encoding = 'utf-8',
293
+ ...restOptions
294
+ } = options;
295
+
296
+ if (!command || typeof command !== 'string') {
297
+ throw new Error('Command is required');
298
+ }
299
+
300
+ if (!Array.isArray(args)) {
301
+ throw new Error('Args must be an array');
302
+ }
303
+
304
+ const result = spawnSync(command, args, {
305
+ encoding,
306
+ timeout,
307
+ maxBuffer,
308
+ shell: false,
309
+ ...restOptions
310
+ });
311
+
312
+ if (result.error) {
313
+ throw result.error;
314
+ }
315
+
316
+ return {
317
+ stdout: result.stdout || '',
318
+ stderr: result.stderr || '',
319
+ status: result.status
320
+ };
321
+ }