keystone-cli 0.4.4 → 0.5.1

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,79 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
+ import * as child_process from 'node:child_process';
3
+ import { MCPClient } from './mcp-client';
4
+
5
+ import { Readable, Writable } from 'node:stream';
6
+
7
+ describe('MCPClient Audit Fixes', () => {
8
+ let spawnSpy: ReturnType<typeof spyOn>;
9
+
10
+ beforeEach(() => {
11
+ spawnSpy = spyOn(child_process, 'spawn').mockReturnValue({
12
+ stdout: new Readable({ read() {} }),
13
+ stdin: new Writable({
14
+ write(c, e, cb) {
15
+ cb();
16
+ },
17
+ }),
18
+ kill: () => {},
19
+ on: () => {},
20
+ // biome-ignore lint/suspicious/noExplicitAny: Mocking complex object
21
+ } as any);
22
+ });
23
+
24
+ afterEach(() => {
25
+ spawnSpy.mockRestore();
26
+ });
27
+
28
+ it('should filter sensitive environment variables', async () => {
29
+ // Set temp environment variables
30
+ process.env.TEST_API_KEY_LEAK = 'secret_value';
31
+ process.env.TEST_SAFE_VAR = 'safe_value';
32
+ process.env.TEST_TOKEN_XYZ = 'secret_token';
33
+
34
+ try {
35
+ await MCPClient.createLocal('node', [], { EXPLICIT_SECRET: 'allowed' });
36
+
37
+ // Assert spawn arguments
38
+ // args: [0]=command, [1]=args, [2]=options
39
+ const call = spawnSpy.mock.lastCall;
40
+ if (!call) throw new Error('spawn not called');
41
+
42
+ const envArg = call[2].env;
43
+
44
+ // Safe vars should remain
45
+ expect(envArg.TEST_SAFE_VAR).toBe('safe_value');
46
+
47
+ // Explicitly passed vars should remain
48
+ expect(envArg.EXPLICIT_SECRET).toBe('allowed');
49
+
50
+ // Sensitive vars should be filtered
51
+ expect(envArg.TEST_API_KEY_LEAK).toBeUndefined();
52
+ expect(envArg.TEST_TOKEN_XYZ).toBeUndefined();
53
+ } finally {
54
+ // Cleanup
55
+ process.env.TEST_API_KEY_LEAK = undefined;
56
+ process.env.TEST_SAFE_VAR = undefined;
57
+ process.env.TEST_TOKEN_XYZ = undefined;
58
+ }
59
+ });
60
+
61
+ it('should allow whitelisted sensitive vars if explicitly provided', async () => {
62
+ process.env.TEST_API_KEY_LEAK = 'secret_value';
63
+
64
+ try {
65
+ // User explicitly asks to pass this env var
66
+ await MCPClient.createLocal('node', [], {
67
+ TEST_API_KEY_LEAK: process.env.TEST_API_KEY_LEAK as string,
68
+ });
69
+
70
+ const call = spawnSpy.mock.lastCall;
71
+ if (!call) throw new Error('spawn not called');
72
+ const envArg = call[2].env;
73
+
74
+ expect(envArg.TEST_API_KEY_LEAK).toBe('secret_value');
75
+ } finally {
76
+ process.env.TEST_API_KEY_LEAK = undefined;
77
+ }
78
+ });
79
+ });
@@ -2,6 +2,12 @@ import { type ChildProcess, spawn } from 'node:child_process';
2
2
  import { type Interface, createInterface } from 'node:readline';
3
3
  import pkg from '../../package.json' with { type: 'json' };
4
4
 
5
+ // MCP Protocol version - update when upgrading to newer MCP spec
6
+ export const MCP_PROTOCOL_VERSION = '2024-11-05';
7
+
8
+ // Maximum buffer size for incoming messages (10MB) to prevent memory exhaustion
9
+ const MAX_BUFFER_SIZE = 10 * 1024 * 1024;
10
+
5
11
  interface MCPTool {
6
12
  name: string;
7
13
  description?: string;
@@ -33,8 +39,19 @@ class StdConfigTransport implements MCPTransport {
33
39
  private rl: Interface;
34
40
 
35
41
  constructor(command: string, args: string[] = [], env: Record<string, string> = {}) {
42
+ // Filter out sensitive environment variables from the host process
43
+ // unless they are explicitly provided in the 'env' argument
44
+ const safeEnv: Record<string, string> = {};
45
+ const sensitivePattern = /(?:key|token|secret|password|credential|auth|private)/i;
46
+
47
+ for (const [key, value] of Object.entries(process.env)) {
48
+ if (value && !sensitivePattern.test(key)) {
49
+ safeEnv[key] = value;
50
+ }
51
+ }
52
+
36
53
  this.process = spawn(command, args, {
37
- env: { ...process.env, ...env },
54
+ env: { ...safeEnv, ...env },
38
55
  stdio: ['pipe', 'pipe', 'inherit'],
39
56
  });
40
57
 
@@ -53,6 +70,14 @@ class StdConfigTransport implements MCPTransport {
53
70
 
54
71
  onMessage(callback: (message: MCPResponse) => void): void {
55
72
  this.rl.on('line', (line) => {
73
+ // Safety check for extremely long lines that might have bypassed readline's internal limits
74
+ if (line.length > MAX_BUFFER_SIZE) {
75
+ process.stderr.write(
76
+ `[MCP Error] Received line exceeding maximum size (${line.length} bytes), ignoring.\n`
77
+ );
78
+ return;
79
+ }
80
+
56
81
  try {
57
82
  const response = JSON.parse(line) as MCPResponse;
58
83
  callback(response);
@@ -78,6 +103,7 @@ class SSETransport implements MCPTransport {
78
103
  private onMessageCallback?: (message: MCPResponse) => void;
79
104
  private abortController: AbortController | null = null;
80
105
  private sessionId?: string;
106
+ private activeReaders: Set<ReadableStreamDefaultReader<Uint8Array>> = new Set();
81
107
 
82
108
  constructor(url: string, headers: Record<string, string> = {}) {
83
109
  this.url = url;
@@ -140,6 +166,9 @@ class SSETransport implements MCPTransport {
140
166
  return;
141
167
  }
142
168
 
169
+ // Track reader for cleanup
170
+ this.activeReaders.add(reader);
171
+
143
172
  // Process the stream in the background
144
173
  (async () => {
145
174
  let buffer = '';
@@ -160,14 +189,26 @@ class SSETransport implements MCPTransport {
160
189
  resolve();
161
190
  }
162
191
  } else if (!currentEvent.event || currentEvent.event === 'message') {
163
- // If we get a message before an endpoint, assume the URL itself is the endpoint
192
+ // If we get a message before an endpoint, verify it's a valid JSON-RPC message
193
+ // before assuming the URL itself is the endpoint.
164
194
  // (Common in some MCP over SSE implementations like GitHub's)
165
195
  if (!this.endpoint) {
166
- this.endpoint = this.url;
167
- if (!isResolved) {
168
- isResolved = true;
169
- clearTimeout(timeoutId);
170
- resolve();
196
+ try {
197
+ const msg = JSON.parse(currentEvent.data || '{}');
198
+ if (
199
+ msg &&
200
+ typeof msg === 'object' &&
201
+ (msg.jsonrpc === '2.0' || msg.id !== undefined)
202
+ ) {
203
+ this.endpoint = this.url;
204
+ if (!isResolved) {
205
+ isResolved = true;
206
+ clearTimeout(timeoutId);
207
+ resolve();
208
+ }
209
+ }
210
+ } catch (e) {
211
+ // Not a valid JSON message, ignore for endpoint discovery
171
212
  }
172
213
  }
173
214
 
@@ -193,7 +234,12 @@ class SSETransport implements MCPTransport {
193
234
  break;
194
235
  }
195
236
 
196
- buffer += decoder.decode(value, { stream: true });
237
+ const decoded = decoder.decode(value, { stream: true });
238
+ if (buffer.length + decoded.length > MAX_BUFFER_SIZE) {
239
+ throw new Error(`SSE buffer size limit exceeded (${MAX_BUFFER_SIZE} bytes)`);
240
+ }
241
+ buffer += decoded;
242
+
197
243
  const lines = buffer.split(/\r\n|\r|\n/);
198
244
  buffer = lines.pop() || '';
199
245
 
@@ -207,6 +253,12 @@ class SSETransport implements MCPTransport {
207
253
  currentEvent.event = line.substring(6).trim();
208
254
  } else if (line.startsWith('data:')) {
209
255
  const data = line.substring(5).trim();
256
+ if (
257
+ currentEvent.data &&
258
+ currentEvent.data.length + data.length > MAX_BUFFER_SIZE
259
+ ) {
260
+ throw new Error(`SSE data size limit exceeded (${MAX_BUFFER_SIZE} bytes)`);
261
+ }
210
262
  currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
211
263
  }
212
264
  }
@@ -229,6 +281,8 @@ class SSETransport implements MCPTransport {
229
281
  clearTimeout(timeoutId);
230
282
  reject(err);
231
283
  }
284
+ } finally {
285
+ this.activeReaders.delete(reader);
232
286
  }
233
287
  })();
234
288
  } catch (err) {
@@ -244,7 +298,7 @@ class SSETransport implements MCPTransport {
244
298
  throw new Error('SSE transport not connected or endpoint not received');
245
299
  }
246
300
 
247
- const headers = {
301
+ const headers: Record<string, string> = {
248
302
  'Content-Type': 'application/json',
249
303
  ...this.headers,
250
304
  };
@@ -273,6 +327,8 @@ class SSETransport implements MCPTransport {
273
327
  if (contentType?.includes('text/event-stream')) {
274
328
  const reader = response.body?.getReader();
275
329
  if (reader) {
330
+ // Track reader for cleanup
331
+ this.activeReaders.add(reader);
276
332
  (async () => {
277
333
  let buffer = '';
278
334
  const decoder = new TextDecoder();
@@ -302,7 +358,12 @@ class SSETransport implements MCPTransport {
302
358
  break;
303
359
  }
304
360
 
305
- buffer += decoder.decode(value, { stream: true });
361
+ const decoded = decoder.decode(value, { stream: true });
362
+ if (buffer.length + decoded.length > MAX_BUFFER_SIZE) {
363
+ throw new Error(`SSE buffer size limit exceeded (${MAX_BUFFER_SIZE} bytes)`);
364
+ }
365
+ buffer += decoded;
366
+
306
367
  const lines = buffer.split(/\r\n|\r|\n/);
307
368
  buffer = lines.pop() || '';
308
369
 
@@ -316,12 +377,20 @@ class SSETransport implements MCPTransport {
316
377
  currentEvent.event = line.substring(6).trim();
317
378
  } else if (line.startsWith('data:')) {
318
379
  const data = line.substring(5).trim();
380
+ if (
381
+ currentEvent.data &&
382
+ currentEvent.data.length + data.length > MAX_BUFFER_SIZE
383
+ ) {
384
+ throw new Error(`SSE data size limit exceeded (${MAX_BUFFER_SIZE} bytes)`);
385
+ }
319
386
  currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
320
387
  }
321
388
  }
322
389
  }
323
390
  } catch (e) {
324
391
  // Ignore stream errors
392
+ } finally {
393
+ this.activeReaders.delete(reader);
325
394
  }
326
395
  })();
327
396
  }
@@ -333,6 +402,11 @@ class SSETransport implements MCPTransport {
333
402
  }
334
403
 
335
404
  close(): void {
405
+ // Cancel all active readers to prevent memory leaks
406
+ for (const reader of this.activeReaders) {
407
+ reader.cancel().catch(() => {});
408
+ }
409
+ this.activeReaders.clear();
336
410
  this.abortController?.abort();
337
411
  }
338
412
  }
@@ -401,25 +475,29 @@ export class MCPClient {
401
475
  };
402
476
 
403
477
  return new Promise((resolve, reject) => {
404
- this.pendingRequests.set(id, resolve);
405
- this.transport.send(message).catch((err) => {
406
- this.pendingRequests.delete(id);
407
- reject(err);
408
- });
409
-
410
- // Add a timeout
411
- setTimeout(() => {
478
+ const timeoutId = setTimeout(() => {
412
479
  if (this.pendingRequests.has(id)) {
413
480
  this.pendingRequests.delete(id);
414
481
  reject(new Error(`MCP request timeout: ${method}`));
415
482
  }
416
483
  }, this.timeout);
484
+
485
+ this.pendingRequests.set(id, (response) => {
486
+ clearTimeout(timeoutId);
487
+ resolve(response);
488
+ });
489
+
490
+ this.transport.send(message).catch((err) => {
491
+ clearTimeout(timeoutId);
492
+ this.pendingRequests.delete(id);
493
+ reject(err);
494
+ });
417
495
  });
418
496
  }
419
497
 
420
498
  async initialize() {
421
499
  return this.request('initialize', {
422
- protocolVersion: '2024-11-05',
500
+ protocolVersion: MCP_PROTOCOL_VERSION,
423
501
  capabilities: {},
424
502
  clientInfo: {
425
503
  name: 'keystone-cli',
@@ -445,6 +523,11 @@ export class MCPClient {
445
523
  }
446
524
 
447
525
  stop() {
526
+ // Reject all pending requests to prevent hanging callers
527
+ for (const [id, resolve] of this.pendingRequests) {
528
+ resolve({ id, error: { code: -1, message: 'MCP client stopped' } });
529
+ }
530
+ this.pendingRequests.clear();
448
531
  this.transport.close();
449
532
  }
450
533
  }
@@ -25,6 +25,7 @@ describe('shell-executor', () => {
25
25
  const step: ShellStep = {
26
26
  id: 'test',
27
27
  type: 'shell',
28
+ needs: [],
28
29
  run: 'echo "hello world"',
29
30
  };
30
31
 
@@ -37,6 +38,7 @@ describe('shell-executor', () => {
37
38
  const step: ShellStep = {
38
39
  id: 'test',
39
40
  type: 'shell',
41
+ needs: [],
40
42
  run: 'echo "${{ inputs.name }}"',
41
43
  };
42
44
  const customContext: ExpressionContext = {
@@ -52,6 +54,7 @@ describe('shell-executor', () => {
52
54
  const step: ShellStep = {
53
55
  id: 'test',
54
56
  type: 'shell',
57
+ needs: [],
55
58
  run: 'echo $TEST_VAR',
56
59
  env: {
57
60
  TEST_VAR: 'env-value',
@@ -66,6 +69,7 @@ describe('shell-executor', () => {
66
69
  const step: ShellStep = {
67
70
  id: 'test',
68
71
  type: 'shell',
72
+ needs: [],
69
73
  run: 'pwd',
70
74
  dir: '/tmp',
71
75
  };
@@ -78,6 +82,7 @@ describe('shell-executor', () => {
78
82
  const step: ShellStep = {
79
83
  id: 'test',
80
84
  type: 'shell',
85
+ needs: [],
81
86
  run: 'echo "error" >&2',
82
87
  };
83
88
 
@@ -89,6 +94,7 @@ describe('shell-executor', () => {
89
94
  const step: ShellStep = {
90
95
  id: 'test',
91
96
  type: 'shell',
97
+ needs: [],
92
98
  run: 'exit 1',
93
99
  };
94
100
 
@@ -96,28 +102,40 @@ describe('shell-executor', () => {
96
102
  expect(result.exitCode).toBe(1);
97
103
  });
98
104
 
99
- it('should warn about shell injection risk', async () => {
100
- const spy = console.warn;
101
- let warned = false;
102
- console.warn = (...args: unknown[]) => {
103
- const msg = args[0];
104
- if (
105
- typeof msg === 'string' &&
106
- msg.includes('WARNING: Command contains shell metacharacters')
107
- ) {
108
- warned = true;
109
- }
105
+ it('should throw error on shell injection risk', async () => {
106
+ const step: ShellStep = {
107
+ id: 'test',
108
+ type: 'shell',
109
+ needs: [],
110
+ run: 'echo "hello" ; rm -rf /tmp/foo',
110
111
  };
111
112
 
113
+ await expect(executeShell(step, context)).rejects.toThrow(/Security Error/);
114
+ });
115
+
116
+ it('should allow legitimate shell variable expansion like ${HOME}', async () => {
112
117
  const step: ShellStep = {
113
118
  id: 'test',
114
119
  type: 'shell',
115
- run: 'echo "hello" ; rm -rf /tmp/foo',
120
+ needs: [],
121
+ run: 'echo ${HOME}',
122
+ };
123
+
124
+ // Should NOT throw - ${HOME} is legitimate
125
+ const result = await executeShell(step, context);
126
+ expect(result.exitCode).toBe(0);
127
+ expect(result.stdout.trim()).toBe(Bun.env.HOME || '');
128
+ });
129
+
130
+ it('should still block dangerous parameter expansion like ${IFS}', async () => {
131
+ const step: ShellStep = {
132
+ id: 'test',
133
+ type: 'shell',
134
+ needs: [],
135
+ run: 'echo ${IFS}',
116
136
  };
117
137
 
118
- await executeShell(step, context);
119
- expect(warned).toBe(true);
120
- console.warn = spy; // Restore
138
+ await expect(executeShell(step, context)).rejects.toThrow(/Security Error/);
121
139
  });
122
140
  });
123
141
  });
@@ -60,23 +60,47 @@ export interface ShellResult {
60
60
  * Check if a command contains potentially dangerous shell metacharacters
61
61
  * Returns true if the command looks like it might contain unescaped user input
62
62
  */
63
+ // Pre-compiled dangerous patterns for performance
64
+ // These patterns are designed to detect likely injection attempts while minimizing false positives
65
+ const DANGEROUS_PATTERNS: RegExp[] = [
66
+ /;\s*\w/, // Command chaining with semicolon (e.g., `; rm -rf /`)
67
+ /\|\s*(?:sh|bash|zsh|ksh|dash|csh|python|python[23]?|node|ruby|perl|php|lua)\b/, // Piping to shell/interpreter (download-and-execute pattern)
68
+ /\|\s*(?:sudo|su)\b/, // Piping to privilege escalation
69
+ /&&\s*(?:rm|chmod|chown|mkfs|dd)\b/, // AND chaining with destructive commands
70
+ /\|\|\s*(?:rm|chmod|chown|mkfs|dd)\b/, // OR chaining with destructive commands
71
+ /`[^`]+`/, // Command substitution with backticks
72
+ /\$\([^)]+\)/, // Command substitution with $()
73
+ />\s*\/dev\/null\s*2>&1\s*&/, // Backgrounding with hidden output (often malicious)
74
+ /rm\s+(-rf?|--recursive)\s+[\/~]/, // Dangerous recursive deletion
75
+ />\s*\/etc\//, // Writing to /etc
76
+ /curl\s+.*\|\s*(?:sh|bash)/, // Download and execute pattern
77
+ /wget\s+.*\|\s*(?:sh|bash)/, // Download and execute pattern
78
+ // Additional patterns for more comprehensive detection
79
+ /base64\s+(-d|--decode)\s*\|/, // Base64 decode piped to another command
80
+ /\beval\s+["'\$]/, // eval with variable/string (likely injection)
81
+ /\bexec\s+\d+[<>]/, // exec with file descriptor redirection
82
+ /python[23]?\s+-c\s*["']/, // Python one-liner with quoted code
83
+ /node\s+(-e|--eval)\s*["']/, // Node.js one-liner with quoted code
84
+ /perl\s+-e\s*["']/, // Perl one-liner with quoted code
85
+ /ruby\s+-e\s*["']/, // Ruby one-liner with quoted code
86
+ /\bdd\s+.*\bof=\//, // dd write operation to root paths
87
+ /chmod\s+[0-7]{3,4}\s+\/(?!tmp)/, // chmod on root paths (except /tmp)
88
+ /mkfs\./, // Filesystem formatting commands
89
+ // Targeted parameter expansion patterns (not all ${} usage)
90
+ /\$\{IFS[}:]/, // IFS manipulation (common injection technique)
91
+ /\$\{[^}]*\$\([^}]*\}/, // Command substitution inside parameter expansion
92
+ /\$\{[^}]*:-[^}]*\$\(/, // Default value with command substitution
93
+ /\$\{[^}]*[`][^}]*\}/, // Backtick inside parameter expansion
94
+ /\\x[0-9a-fA-F]{2}/, // Hex escaping attempts
95
+ /\\[0-7]{3}/, // Octal escaping attempts
96
+ /<<<\s*/, // Here-strings (can be used for injection)
97
+ /\d*<&\s*\d*/, // File descriptor duplication
98
+ /\d*>&-\s*/, // Closing file descriptors
99
+ ];
100
+
63
101
  export function detectShellInjectionRisk(command: string): boolean {
64
- // Common shell metacharacters that indicate potential injection
65
- const dangerousPatterns = [
66
- /;[\s]*\w/, // Command chaining with semicolon
67
- /\|[\s]*\w/, // Piping (legitimate uses exist, but worth warning)
68
- /&&[\s]*\w/, // AND chaining
69
- /\|\|[\s]*\w/, // OR chaining
70
- /`[^`]+`/, // Command substitution with backticks
71
- /\$\([^)]+\)/, // Command substitution with $()
72
- />\s*\/dev\/null/, // Output redirection (common in attacks)
73
- /rm\s+-rf/, // Dangerous deletion command
74
- />\s*[~\/]/, // File redirection to suspicious paths
75
- /curl\s+.*\|\s*sh/, // Download and execute pattern
76
- /wget\s+.*\|\s*sh/, // Download and execute pattern
77
- ];
78
-
79
- return dangerousPatterns.some((pattern) => pattern.test(command));
102
+ // Check against pre-compiled patterns
103
+ return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command));
80
104
  }
81
105
 
82
106
  /**
@@ -91,9 +115,9 @@ export async function executeShell(
91
115
  const command = ExpressionEvaluator.evaluateString(step.run, context);
92
116
 
93
117
  // Check for potential shell injection risks
94
- if (detectShellInjectionRisk(command)) {
95
- logger.warn(
96
- `\n⚠️ WARNING: Command contains shell metacharacters that may indicate injection risk:\n Command: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}\n To safely interpolate user inputs, use the escape() function.\n Example: run: echo \${{ escape(inputs.user_input) }}\n`
118
+ if (!step.allowInsecure && detectShellInjectionRisk(command)) {
119
+ throw new Error(
120
+ `Security Error: Command contains shell metacharacters that may indicate injection risk:\n Command: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}\n To execute this command safely, ensure all user inputs are wrapped in \${{ escape(input) }}.\n\n If you trust this workflow and its inputs, you may need to refactor the step to avoid complex shell chains or use a stricter input validation.\n Or, if you really trust this command, you can set 'allowInsecure: true' in the step definition.`
97
121
  );
98
122
  }
99
123
 
@@ -107,31 +131,78 @@ export async function executeShell(
107
131
 
108
132
  // Set working directory if specified
109
133
  const cwd = step.dir ? ExpressionEvaluator.evaluateString(step.dir, context) : undefined;
134
+ const mergedEnv = Object.keys(env).length > 0 ? { ...Bun.env, ...env } : Bun.env;
135
+
136
+ // Safe Fast Path: If command contains only safe characters (alphanumeric, -, _, ., /) and spaces,
137
+ // we can split it and execute directly without a shell.
138
+ // This completely eliminates shell injection risks for simple commands.
139
+ const isSimpleCommand = /^[a-zA-Z0-9_\-./]+(?: [a-zA-Z0-9_\-./]+)*$/.test(command);
140
+
141
+ // Common shell builtins that must run in a shell
142
+ const splitArgs = command.split(/\s+/);
143
+ const cmd = splitArgs[0];
144
+ const isBuiltin = [
145
+ 'exit',
146
+ 'cd',
147
+ 'export',
148
+ 'unset',
149
+ 'source',
150
+ '.',
151
+ 'alias',
152
+ 'unalias',
153
+ 'eval',
154
+ 'set',
155
+ ].includes(cmd);
110
156
 
111
157
  try {
112
- // Execute command using sh -c to allow shell parsing
113
- let proc = $`sh -c ${command}`.quiet();
114
-
115
- // Apply environment variables - merge with Bun.env to preserve system PATH and other variables
116
- if (Object.keys(env).length > 0) {
117
- proc = proc.env({ ...Bun.env, ...env });
118
- }
119
-
120
- // Apply working directory
121
- if (cwd) {
122
- proc = proc.cwd(cwd);
158
+ let stdoutString = '';
159
+ let stderrString = '';
160
+ let exitCode = 0;
161
+
162
+ if (isSimpleCommand && !isBuiltin) {
163
+ // split by spaces
164
+ const args = splitArgs.slice(1);
165
+ if (!cmd) throw new Error('Empty command');
166
+
167
+ const proc = Bun.spawn([cmd, ...args], {
168
+ cwd,
169
+ env: mergedEnv,
170
+ stdout: 'pipe',
171
+ stderr: 'pipe',
172
+ });
173
+
174
+ const stdoutText = await new Response(proc.stdout).text();
175
+ const stderrText = await new Response(proc.stderr).text();
176
+
177
+ // Wait for exit
178
+ exitCode = await proc.exited;
179
+ stdoutString = stdoutText;
180
+ stderrString = stderrText;
181
+ } else {
182
+ // Fallback to sh -c for complex commands (pipes, redirects, quotes)
183
+ // Execute command using sh -c to allow shell parsing
184
+ let proc = $`sh -c ${command}`.quiet();
185
+
186
+ // Apply environment variables - merge with Bun.env to preserve system PATH and other variables
187
+ if (Object.keys(env).length > 0) {
188
+ proc = proc.env({ ...Bun.env, ...env });
189
+ }
190
+
191
+ // Apply working directory
192
+ if (cwd) {
193
+ proc = proc.cwd(cwd);
194
+ }
195
+
196
+ // Execute and capture result
197
+ const result = await proc;
198
+ stdoutString = await result.text();
199
+ stderrString = result.stderr ? result.stderr.toString() : '';
200
+ exitCode = result.exitCode;
123
201
  }
124
202
 
125
- // Execute and capture result
126
- const result = await proc;
127
-
128
- const stdout = await result.text();
129
- const stderr = result.stderr ? result.stderr.toString() : '';
130
- const exitCode = result.exitCode;
131
-
132
203
  return {
133
- stdout,
134
- stderr,
204
+ stdout: stdoutString,
205
+ stderr: stderrString,
135
206
  exitCode,
136
207
  };
137
208
  } catch (error) {