cli4ai 1.0.3 → 1.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.
@@ -104,6 +104,12 @@ export interface Config {
104
104
  port: number;
105
105
  };
106
106
 
107
+ // Audit logging configuration
108
+ audit: {
109
+ /** Enable audit logging for MCP tool calls */
110
+ enabled: boolean;
111
+ };
112
+
107
113
  // Telemetry (future)
108
114
  telemetry: boolean;
109
115
  }
@@ -128,6 +134,9 @@ export const DEFAULT_CONFIG: Config = {
128
134
  transport: 'stdio',
129
135
  port: 3100
130
136
  },
137
+ audit: {
138
+ enabled: true
139
+ },
131
140
  telemetry: false
132
141
  };
133
142
 
@@ -197,6 +206,12 @@ function deepMerge(target: Config, source: Partial<Config>): Config {
197
206
  port: source.mcp.port ?? target.mcp.port
198
207
  };
199
208
  }
209
+ // Deep merge audit config
210
+ if (source.audit !== undefined) {
211
+ result.audit = {
212
+ enabled: source.audit.enabled ?? target.audit.enabled
213
+ };
214
+ }
200
215
 
201
216
  return result;
202
217
  }
@@ -334,7 +349,14 @@ export function addLocalRegistry(path: string): void {
334
349
  */
335
350
  export function removeLocalRegistry(path: string): void {
336
351
  const absolutePath = resolve(path);
337
- const safePath = validateSymlinkTarget(absolutePath) ?? absolutePath;
352
+
353
+ // SECURITY: Validate symlink target consistently with addLocalRegistry
354
+ const safePath = validateSymlinkTarget(absolutePath);
355
+ if (!safePath) {
356
+ outputError('INVALID_INPUT', `Registry path points to an unsafe location: ${absolutePath}`, {
357
+ hint: 'Symlinks must point to directories within safe locations'
358
+ });
359
+ }
338
360
 
339
361
  let removed = false;
340
362
  updateConfig((config) => {
@@ -536,7 +558,7 @@ export function getGlobalPackages(): InstalledPackage[] {
536
558
  name: manifest.name,
537
559
  version: manifest.version,
538
560
  path: safePath,
539
- source: 'local', // TODO: track actual source
561
+ source: 'registry',
540
562
  installedAt: new Date().toISOString()
541
563
  });
542
564
  } catch {
@@ -31,6 +31,14 @@ function expandTilde(path: string): string {
31
31
 
32
32
  export type ExecuteCaptureMode = 'inherit' | 'pipe';
33
33
 
34
+ /**
35
+ * Permission scope levels for tool execution
36
+ * - read: Only allow read operations (no mutations)
37
+ * - write: Allow write operations but no destructive actions
38
+ * - full: Full access (default)
39
+ */
40
+ export type ScopeLevel = 'read' | 'write' | 'full';
41
+
34
42
  export interface ExecuteToolOptions {
35
43
  packageName: string;
36
44
  command?: string;
@@ -41,6 +49,10 @@ export interface ExecuteToolOptions {
41
49
  capture: ExecuteCaptureMode;
42
50
  timeoutMs?: number;
43
51
  teeStderr?: boolean;
52
+ /** Permission scope for the tool */
53
+ scope?: ScopeLevel;
54
+ /** Run in sandboxed environment with restricted file system access */
55
+ sandbox?: boolean;
44
56
  }
45
57
 
46
58
  export interface ExecuteToolResult {
@@ -348,6 +360,39 @@ function buildRuntimeCommand(entryPath: string, cmdArgs: string[]): { execCmd: s
348
360
  return { execCmd: 'node', execArgs: [entryPath, ...cmdArgs], runtime: 'node' };
349
361
  }
350
362
 
363
+ /**
364
+ * Build security environment variables for scope and sandbox restrictions
365
+ */
366
+ function buildSecurityEnv(
367
+ scope: ScopeLevel,
368
+ sandbox: boolean,
369
+ cwd: string
370
+ ): Record<string, string> {
371
+ const env: Record<string, string> = {};
372
+
373
+ // Set scope environment variable for tools to respect
374
+ env.CLI4AI_SCOPE = scope;
375
+
376
+ // Sandbox restrictions
377
+ if (sandbox) {
378
+ env.CLI4AI_SANDBOX = '1';
379
+
380
+ // Restrict file system access to temp directories and package directory
381
+ // Tools should check these env vars and restrict their operations
382
+ const tmpDir = process.env.TMPDIR || process.env.TMP || process.env.TEMP || '/tmp';
383
+ env.CLI4AI_SANDBOX_ALLOWED_PATHS = [
384
+ tmpDir,
385
+ cwd, // Allow access to current working directory
386
+ ].join(':');
387
+
388
+ // Restrict network access in sandbox mode
389
+ // Tools should check this and limit network operations
390
+ env.CLI4AI_SANDBOX_NETWORK = 'restricted';
391
+ }
392
+
393
+ return env;
394
+ }
395
+
351
396
  async function ensureRuntimeAvailable(): Promise<void> {
352
397
  if (!commandExists('node')) {
353
398
  log('⚠️ Node.js is required to run this tool\n');
@@ -409,6 +454,19 @@ export async function executeTool(options: ExecuteToolOptions): Promise<ExecuteT
409
454
 
410
455
  const teeStderr = options.teeStderr ?? true;
411
456
 
457
+ // Build security environment for scope and sandbox
458
+ const scope = options.scope ?? 'full';
459
+ const sandbox = options.sandbox ?? false;
460
+ const securityEnv = buildSecurityEnv(scope, sandbox, invocationDir);
461
+
462
+ // Log security restrictions if active
463
+ if (scope !== 'full' || sandbox) {
464
+ const restrictions: string[] = [];
465
+ if (scope !== 'full') restrictions.push(`scope=${scope}`);
466
+ if (sandbox) restrictions.push('sandbox=enabled');
467
+ log(`🔒 Security: ${restrictions.join(', ')}`);
468
+ }
469
+
412
470
  if (options.capture === 'inherit') {
413
471
  const proc = spawn(execCmd, execArgs, {
414
472
  stdio: 'inherit',
@@ -421,6 +479,7 @@ export async function executeTool(options: ExecuteToolOptions): Promise<ExecuteT
421
479
  C4AI_PACKAGE_NAME: pkg.name,
422
480
  C4AI_ENTRY: entryPath,
423
481
  ...secretsEnv,
482
+ ...securityEnv,
424
483
  ...(options.env ?? {})
425
484
  }
426
485
  });
@@ -453,6 +512,7 @@ export async function executeTool(options: ExecuteToolOptions): Promise<ExecuteT
453
512
  C4AI_PACKAGE_NAME: pkg.name,
454
513
  C4AI_ENTRY: entryPath,
455
514
  ...secretsEnv,
515
+ ...securityEnv,
456
516
  ...(options.env ?? {})
457
517
  }
458
518
  });
package/src/core/link.ts CHANGED
@@ -204,6 +204,21 @@ export function isPackageLinked(packageName: string): boolean {
204
204
  * Get PATH setup instructions
205
205
  */
206
206
  export function getPathInstructions(): string {
207
+ if (process.platform === 'win32') {
208
+ return `
209
+ To use globally installed cli4ai packages from anywhere, add the bin directory to your PATH:
210
+
211
+ ${C4AI_BIN}
212
+
213
+ On Windows, you can:
214
+ 1. Use System Settings > Edit environment variables (GUI)
215
+ 2. Or run in PowerShell (current session): $env:PATH += ";${C4AI_BIN}"
216
+ 3. Or run in PowerShell (permanent): [Environment]::SetEnvironmentVariable("PATH", $env:PATH + ";${C4AI_BIN}", "User")
217
+
218
+ Then restart your terminal.
219
+ `.trim();
220
+ }
221
+
207
222
  const shell = process.env.SHELL || '/bin/bash';
208
223
  const isZsh = shell.includes('zsh');
209
224
  const rcFile = isZsh ? '~/.zshrc' : '~/.bashrc';
@@ -226,5 +241,6 @@ Or start a new terminal session.
226
241
  */
227
242
  export function isBinInPath(): boolean {
228
243
  const pathEnv = process.env.PATH || '';
229
- return pathEnv.split(':').some(p => resolve(p) === C4AI_BIN);
244
+ const separator = process.platform === 'win32' ? ';' : ':';
245
+ return pathEnv.split(separator).some(p => resolve(p) === C4AI_BIN);
230
246
  }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Remote client for executing cli4ai commands on remote hosts.
3
+ *
4
+ * This module provides functions to call remote cli4ai services
5
+ * configured via `cli4ai remotes add`.
6
+ */
7
+
8
+ import { request as httpRequest, type RequestOptions, type OutgoingHttpHeaders } from 'http';
9
+ import { request as httpsRequest } from 'https';
10
+ import { getRemoteOrThrow, updateRemoteLastConnected, type RemoteHost } from './remotes.js';
11
+ import type { ScopeLevel } from './execute.js';
12
+ import type { RoutineRunSummary } from './routine-engine.js';
13
+
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+ // TYPES
16
+ // ═══════════════════════════════════════════════════════════════════════════
17
+
18
+ export interface RemoteRunOptions {
19
+ /** Package name to execute */
20
+ package: string;
21
+ /** Command within the package */
22
+ command?: string;
23
+ /** Arguments to pass */
24
+ args?: string[];
25
+ /** Environment variables */
26
+ env?: Record<string, string>;
27
+ /** Standard input to pass */
28
+ stdin?: string;
29
+ /** Timeout in milliseconds */
30
+ timeout?: number;
31
+ /** Scope level for execution */
32
+ scope?: ScopeLevel;
33
+ }
34
+
35
+ export interface RemoteRunResult {
36
+ success: boolean;
37
+ exitCode: number;
38
+ stdout?: string;
39
+ stderr?: string;
40
+ durationMs: number;
41
+ error?: {
42
+ code: string;
43
+ message: string;
44
+ details?: Record<string, unknown>;
45
+ };
46
+ }
47
+
48
+ export interface RemoteHealthResult {
49
+ status: 'ok';
50
+ hostname: string;
51
+ version: string;
52
+ uptime: number;
53
+ }
54
+
55
+ export interface RemotePackageInfo {
56
+ name: string;
57
+ version: string;
58
+ description?: string;
59
+ commands?: Record<string, { description: string }>;
60
+ }
61
+
62
+ export interface RemotePackageList {
63
+ packages: Array<{
64
+ name: string;
65
+ version: string;
66
+ path: string;
67
+ source: 'local' | 'registry';
68
+ }>;
69
+ }
70
+
71
+ // ═══════════════════════════════════════════════════════════════════════════
72
+ // ERRORS
73
+ // ═══════════════════════════════════════════════════════════════════════════
74
+
75
+ export class RemoteConnectionError extends Error {
76
+ constructor(
77
+ public remoteName: string,
78
+ public url: string,
79
+ message: string
80
+ ) {
81
+ super(`Failed to connect to remote "${remoteName}" at ${url}: ${message}`);
82
+ this.name = 'RemoteConnectionError';
83
+ }
84
+ }
85
+
86
+ export class RemoteApiError extends Error {
87
+ constructor(
88
+ public remoteName: string,
89
+ public statusCode: number,
90
+ public code: string,
91
+ message: string,
92
+ public details?: Record<string, unknown>
93
+ ) {
94
+ super(`Remote "${remoteName}" error [${code}]: ${message}`);
95
+ this.name = 'RemoteApiError';
96
+ }
97
+ }
98
+
99
+ // ═══════════════════════════════════════════════════════════════════════════
100
+ // HTTP CLIENT
101
+ // ═══════════════════════════════════════════════════════════════════════════
102
+
103
+ interface HttpResponse {
104
+ statusCode: number;
105
+ body: string;
106
+ }
107
+
108
+ function makeRequest(
109
+ url: URL,
110
+ method: string,
111
+ headers: Record<string, string>,
112
+ body?: string,
113
+ timeoutMs: number = 30000
114
+ ): Promise<HttpResponse> {
115
+ return new Promise((resolve, reject) => {
116
+ const isHttps = url.protocol === 'https:';
117
+ const requestFn = isHttps ? httpsRequest : httpRequest;
118
+
119
+ const reqHeaders: OutgoingHttpHeaders = {
120
+ ...headers,
121
+ 'Content-Type': 'application/json'
122
+ };
123
+
124
+ if (body) {
125
+ reqHeaders['Content-Length'] = Buffer.byteLength(body);
126
+ }
127
+
128
+ const options: RequestOptions = {
129
+ method,
130
+ hostname: url.hostname,
131
+ port: url.port || (isHttps ? 443 : 80),
132
+ path: url.pathname + url.search,
133
+ headers: reqHeaders,
134
+ timeout: timeoutMs
135
+ };
136
+
137
+ const req = requestFn(options, (res) => {
138
+ const chunks: Buffer[] = [];
139
+ res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
140
+ res.on('end', () => {
141
+ resolve({
142
+ statusCode: res.statusCode ?? 500,
143
+ body: Buffer.concat(chunks).toString('utf-8')
144
+ });
145
+ });
146
+ });
147
+
148
+ req.on('error', (err) => {
149
+ reject(err);
150
+ });
151
+
152
+ req.on('timeout', () => {
153
+ req.destroy();
154
+ reject(new Error('Request timeout'));
155
+ });
156
+
157
+ if (body) {
158
+ req.write(body);
159
+ }
160
+
161
+ req.end();
162
+ });
163
+ }
164
+
165
+ function buildHeaders(remote: RemoteHost): Record<string, string> {
166
+ const headers: Record<string, string> = {
167
+ 'Accept': 'application/json'
168
+ };
169
+
170
+ if (remote.apiKey) {
171
+ headers['X-API-Key'] = remote.apiKey;
172
+ }
173
+
174
+ return headers;
175
+ }
176
+
177
+ // ═══════════════════════════════════════════════════════════════════════════
178
+ // CLIENT FUNCTIONS
179
+ // ═══════════════════════════════════════════════════════════════════════════
180
+
181
+ /**
182
+ * Check health of a remote service
183
+ */
184
+ export async function remoteHealth(remoteName: string): Promise<RemoteHealthResult> {
185
+ const remote = getRemoteOrThrow(remoteName);
186
+ const url = new URL('/health', remote.url);
187
+
188
+ try {
189
+ const response = await makeRequest(url, 'GET', buildHeaders(remote));
190
+
191
+ if (response.statusCode !== 200) {
192
+ const error = JSON.parse(response.body)?.error;
193
+ throw new RemoteApiError(
194
+ remoteName,
195
+ response.statusCode,
196
+ error?.code ?? 'API_ERROR',
197
+ error?.message ?? 'Unknown error',
198
+ error?.details
199
+ );
200
+ }
201
+
202
+ const data = JSON.parse(response.body) as RemoteHealthResult;
203
+ updateRemoteLastConnected(remoteName);
204
+
205
+ return data;
206
+ } catch (err) {
207
+ if (err instanceof RemoteApiError) throw err;
208
+ throw new RemoteConnectionError(
209
+ remoteName,
210
+ remote.url,
211
+ err instanceof Error ? err.message : String(err)
212
+ );
213
+ }
214
+ }
215
+
216
+ /**
217
+ * List packages on a remote
218
+ */
219
+ export async function remoteListPackages(remoteName: string): Promise<RemotePackageList> {
220
+ const remote = getRemoteOrThrow(remoteName);
221
+ const url = new URL('/packages', remote.url);
222
+
223
+ try {
224
+ const response = await makeRequest(url, 'GET', buildHeaders(remote));
225
+
226
+ if (response.statusCode !== 200) {
227
+ const error = JSON.parse(response.body)?.error;
228
+ throw new RemoteApiError(
229
+ remoteName,
230
+ response.statusCode,
231
+ error?.code ?? 'API_ERROR',
232
+ error?.message ?? 'Unknown error',
233
+ error?.details
234
+ );
235
+ }
236
+
237
+ updateRemoteLastConnected(remoteName);
238
+ return JSON.parse(response.body) as RemotePackageList;
239
+ } catch (err) {
240
+ if (err instanceof RemoteApiError) throw err;
241
+ throw new RemoteConnectionError(
242
+ remoteName,
243
+ remote.url,
244
+ err instanceof Error ? err.message : String(err)
245
+ );
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Get package info from a remote
251
+ */
252
+ export async function remotePackageInfo(remoteName: string, packageName: string): Promise<RemotePackageInfo | null> {
253
+ const remote = getRemoteOrThrow(remoteName);
254
+ const url = new URL(`/packages/${encodeURIComponent(packageName)}`, remote.url);
255
+
256
+ try {
257
+ const response = await makeRequest(url, 'GET', buildHeaders(remote));
258
+
259
+ if (response.statusCode === 404) {
260
+ return null;
261
+ }
262
+
263
+ if (response.statusCode !== 200) {
264
+ const error = JSON.parse(response.body)?.error;
265
+ throw new RemoteApiError(
266
+ remoteName,
267
+ response.statusCode,
268
+ error?.code ?? 'API_ERROR',
269
+ error?.message ?? 'Unknown error',
270
+ error?.details
271
+ );
272
+ }
273
+
274
+ updateRemoteLastConnected(remoteName);
275
+ return JSON.parse(response.body) as RemotePackageInfo;
276
+ } catch (err) {
277
+ if (err instanceof RemoteApiError) throw err;
278
+ throw new RemoteConnectionError(
279
+ remoteName,
280
+ remote.url,
281
+ err instanceof Error ? err.message : String(err)
282
+ );
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Run a tool on a remote
288
+ */
289
+ export async function remoteRunTool(remoteName: string, options: RemoteRunOptions): Promise<RemoteRunResult> {
290
+ const remote = getRemoteOrThrow(remoteName);
291
+ const url = new URL('/run', remote.url);
292
+
293
+ const body = JSON.stringify({
294
+ package: options.package,
295
+ command: options.command,
296
+ args: options.args,
297
+ env: options.env,
298
+ stdin: options.stdin,
299
+ timeout: options.timeout,
300
+ scope: options.scope
301
+ });
302
+
303
+ // Use longer timeout for execution (tool timeout + network overhead)
304
+ const requestTimeout = (options.timeout ?? 30000) + 10000;
305
+
306
+ try {
307
+ const response = await makeRequest(url, 'POST', buildHeaders(remote), body, requestTimeout);
308
+
309
+ const data = JSON.parse(response.body);
310
+
311
+ // Check for API-level error
312
+ if (data.error && response.statusCode >= 400 && response.statusCode !== 500) {
313
+ throw new RemoteApiError(
314
+ remoteName,
315
+ response.statusCode,
316
+ data.error.code ?? 'API_ERROR',
317
+ data.error.message ?? 'Unknown error',
318
+ data.error.details
319
+ );
320
+ }
321
+
322
+ updateRemoteLastConnected(remoteName);
323
+
324
+ // Return the run result (may indicate success: false for tool failure)
325
+ return data as RemoteRunResult;
326
+ } catch (err) {
327
+ if (err instanceof RemoteApiError) throw err;
328
+ throw new RemoteConnectionError(
329
+ remoteName,
330
+ remote.url,
331
+ err instanceof Error ? err.message : String(err)
332
+ );
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Run a routine on a remote
338
+ */
339
+ export async function remoteRunRoutine(
340
+ remoteName: string,
341
+ routineName: string,
342
+ vars?: Record<string, string>
343
+ ): Promise<RoutineRunSummary> {
344
+ const remote = getRemoteOrThrow(remoteName);
345
+ const url = new URL(`/routines/${encodeURIComponent(routineName)}/run`, remote.url);
346
+
347
+ const body = vars ? JSON.stringify({ vars }) : '{}';
348
+
349
+ try {
350
+ const response = await makeRequest(url, 'POST', buildHeaders(remote), body, 300000); // 5 min timeout for routines
351
+
352
+ if (response.statusCode === 404) {
353
+ throw new RemoteApiError(
354
+ remoteName,
355
+ 404,
356
+ 'NOT_FOUND',
357
+ `Routine not found: ${routineName}`
358
+ );
359
+ }
360
+
361
+ const data = JSON.parse(response.body);
362
+
363
+ if (data.error && response.statusCode >= 400) {
364
+ throw new RemoteApiError(
365
+ remoteName,
366
+ response.statusCode,
367
+ data.error.code ?? 'API_ERROR',
368
+ data.error.message ?? 'Unknown error',
369
+ data.error.details
370
+ );
371
+ }
372
+
373
+ updateRemoteLastConnected(remoteName);
374
+ return data as RoutineRunSummary;
375
+ } catch (err) {
376
+ if (err instanceof RemoteApiError) throw err;
377
+ throw new RemoteConnectionError(
378
+ remoteName,
379
+ remote.url,
380
+ err instanceof Error ? err.message : String(err)
381
+ );
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Test connection to a remote
387
+ */
388
+ export async function testRemoteConnection(remoteName: string): Promise<{ success: boolean; message: string; details?: Record<string, unknown> }> {
389
+ try {
390
+ const health = await remoteHealth(remoteName);
391
+ return {
392
+ success: true,
393
+ message: `Connected to ${health.hostname}`,
394
+ details: {
395
+ hostname: health.hostname,
396
+ version: health.version,
397
+ uptime: health.uptime
398
+ }
399
+ };
400
+ } catch (err) {
401
+ if (err instanceof RemoteConnectionError) {
402
+ return {
403
+ success: false,
404
+ message: err.message
405
+ };
406
+ }
407
+ if (err instanceof RemoteApiError) {
408
+ return {
409
+ success: false,
410
+ message: `${err.code}: ${err.message}`,
411
+ details: err.details
412
+ };
413
+ }
414
+ return {
415
+ success: false,
416
+ message: err instanceof Error ? err.message : String(err)
417
+ };
418
+ }
419
+ }