Skip to content

Commit 03cdc44

Browse files
committed
refactor(satellite): update command path handling for dynamic resolution
1 parent d8e2a36 commit 03cdc44

File tree

4 files changed

+158
-32
lines changed

4 files changed

+158
-32
lines changed

services/satellite/src/config/security-validation.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,12 @@ export interface ValidationResult {
2323
export const ALLOWED_COMMANDS = new Set(['npx', 'node', 'uvx', 'python', 'python3']);
2424

2525
/**
26-
* Command path mappings for nsjail (which requires full paths)
26+
* DEPRECATED: Command paths are now resolved dynamically at runtime
27+
* See runtime-validator.ts for dynamic path resolution
28+
* This is kept for backwards compatibility but should not be used
2729
*/
2830
export const COMMAND_PATHS: Record<string, string> = {
29-
'npx': '/usr/bin/npx',
30-
'node': '/usr/bin/node',
31-
'uvx': '/usr/bin/uvx',
32-
'python': '/usr/bin/python',
33-
'python3': '/usr/bin/python3'
31+
// Paths resolved dynamically - see DEPLOYSTACK_COMMAND_CACHE global
3432
};
3533

3634
/**
@@ -192,9 +190,13 @@ export function validateArgs(args: string[], logger?: Logger): ValidationResult
192190
}
193191

194192
/**
195-
* Resolves command to full path for nsjail execution
196-
* SECURE VERSION: Only returns paths for allowed commands
193+
* DEPRECATED: Resolves command to full path for nsjail execution
197194
*
195+
* This function is deprecated. Command paths are now resolved dynamically
196+
* at startup and cached in DEPLOYSTACK_COMMAND_CACHE global.
197+
* Use the cache directly via nsjail-spawner's getCommandPath() function.
198+
*
199+
* @deprecated Use dynamic path resolution from runtime-validator.ts
198200
* @param command - The command name (e.g., 'npx', 'node')
199201
* @param logger - Logger for security warnings
200202
* @returns Full path to command
@@ -207,16 +209,18 @@ export function resolveCommandPath(command: string, logger?: Logger): string {
207209
throw new Error(validation.error || 'Command not allowed');
208210
}
209211

210-
// Get the path from our known mappings
212+
// Get path from runtime-resolved cache
213+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
214+
const cache = (global as any).DEPLOYSTACK_COMMAND_CACHE as Map<string, string> | undefined;
211215
const normalizedCommand = command.trim().toLowerCase();
212-
const path = COMMAND_PATHS[normalizedCommand];
213216

214-
if (!path) {
215-
// This shouldn't happen if ALLOWED_COMMANDS and COMMAND_PATHS are in sync
216-
throw new Error(`No path mapping for command '${command}'`);
217+
if (cache && cache.has(normalizedCommand)) {
218+
return cache.get(normalizedCommand)!;
217219
}
218220

219-
return path;
221+
// Fallback for backwards compatibility (should not happen after initialization)
222+
console.warn(`WARNING: Command ${command} not found in cache, using /usr/bin fallback`);
223+
return `/usr/bin/${normalizedCommand}`;
220224
}
221225

222226
/**

services/satellite/src/process/nsjail-spawner.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { MCPServerConfig } from './types';
77
import { nsjailConfig, mcpCacheBaseDir, BLOCKED_ENV_VARS } from '../config/nsjail';
88
import {
99
validateCommand,
10-
validateArgs,
11-
COMMAND_PATHS
10+
validateArgs
1211
} from '../config/security-validation';
1312

1413
/**
@@ -18,14 +17,21 @@ import {
1817
const ALLOWED_BUILD_COMMANDS = new Set(['npm', 'uv', 'pip', 'pip3']);
1918

2019
/**
21-
* Build command path mappings for nsjail
20+
* Get command path from global cache
21+
* Falls back to /usr/bin if cache miss (should not happen after initialization)
2222
*/
23-
const BUILD_COMMAND_PATHS: Record<string, string> = {
24-
'npm': '/usr/bin/npm',
25-
'uv': '/usr/bin/uv',
26-
'pip': '/usr/bin/pip',
27-
'pip3': '/usr/bin/pip3'
28-
};
23+
function getCommandPath(command: string): string {
24+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
25+
const cache = (global as any).DEPLOYSTACK_COMMAND_CACHE as Map<string, string> | undefined;
26+
27+
if (cache && cache.has(command)) {
28+
return cache.get(command)!;
29+
}
30+
31+
// Fallback to /usr/bin (should not happen after proper initialization)
32+
console.warn(`WARNING: Command ${command} not found in cache, using /usr/bin fallback`);
33+
return `/usr/bin/${command}`;
34+
}
2935

3036
/**
3137
* Options for sandboxed build commands
@@ -112,6 +118,7 @@ export class ProcessSpawner {
112118
* Resolve command to full path for nsjail execution
113119
* SECURE: Only allows commands from the allowlist, rejects absolute paths
114120
* nsjail requires full paths for command execution
121+
* Uses dynamically resolved paths from startup cache
115122
*/
116123
resolveCommandPath(command: string): string {
117124
// Validate command first (defense in depth - backend should have validated)
@@ -125,18 +132,18 @@ export class ProcessSpawner {
125132
throw new Error(validation.error || `Command '${command}' not allowed`);
126133
}
127134

128-
// Get path from secure mappings (only known-safe commands)
135+
// Get path from runtime-resolved cache (dynamically found at startup)
129136
const normalizedCommand = command.trim().toLowerCase();
130-
const path = COMMAND_PATHS[normalizedCommand];
137+
const path = getCommandPath(normalizedCommand);
131138

132139
if (!path) {
133-
// This shouldn't happen if ALLOWED_COMMANDS and COMMAND_PATHS are in sync
140+
// This shouldn't happen if command cache was initialized
134141
this.logger.error({
135142
operation: 'resolve_command_path_no_mapping',
136143
command,
137144
normalizedCommand
138-
}, `No path mapping found for allowed command '${command}'`);
139-
throw new Error(`No path mapping for command '${command}'`);
145+
}, `No path found for command '${command}' in cache`);
146+
throw new Error(`Command path not found: ${command}`);
140147
}
141148

142149
this.logger.debug({
@@ -448,10 +455,10 @@ export class ProcessSpawner {
448455
throw new Error(`Build command '${command}' not allowed. Allowed: ${Array.from(ALLOWED_BUILD_COMMANDS).join(', ')}`);
449456
}
450457

451-
// Get command path
452-
const commandPath = BUILD_COMMAND_PATHS[normalizedCommand];
458+
// Get command path from runtime-resolved cache
459+
const commandPath = getCommandPath(normalizedCommand);
453460
if (!commandPath) {
454-
throw new Error(`No path mapping for build command '${command}'`);
461+
throw new Error(`Command path not found for build command '${command}'`);
455462
}
456463

457464
// Get current user UID and GID

services/satellite/src/server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { McpActivityTracker } from './services/mcp-activity-tracker';
2222
import { ToolSearchService } from './services/tool-search-service';
2323
import { OAuthTokenService } from './services/oauth-token-service';
2424
import { SsePingService } from './services/sse-ping-service';
25-
import { validateSystemRuntimes } from './utils/runtime-validator';
25+
import { validateSystemRuntimes, initializeCommandCache } from './utils/runtime-validator';
2626

2727
/**
2828
* Validate registration token format and availability
@@ -165,6 +165,12 @@ export async function createServer() {
165165
tempLogger.info({ operation: 'runtime_validation_complete' }, 'System runtime validation passed');
166166
}
167167

168+
// Initialize command path cache after validation
169+
const commandCache = initializeCommandCache(tempLogger);
170+
171+
// Store command cache globally for access by process spawners
172+
(global as any).DEPLOYSTACK_COMMAND_CACHE = commandCache;
173+
168174
const server = fastify({
169175
logger: loggerConfig,
170176
disableRequestLogging: true,

services/satellite/src/utils/runtime-validator.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,115 @@ function buildWarningMessage(result: RuntimeCheckResult): string {
247247
return lines.join('\n');
248248
}
249249

250+
/**
251+
* Validate command path for security
252+
* Only allow paths in known safe directories
253+
*/
254+
function validateCommandPath(commandPath: string): boolean {
255+
// Must be absolute
256+
if (!commandPath.startsWith('/')) return false;
257+
258+
// Must match allowed patterns
259+
const allowedPatterns = [
260+
/^\/usr\/bin\//,
261+
/^\/usr\/local\/bin\//,
262+
/^\/bin\//,
263+
/^\/opt\/[^/]+\/bin\//
264+
];
265+
266+
// Add HOME-relative pattern if HOME is set
267+
if (process.env.HOME) {
268+
const homePattern = new RegExp(`^${process.env.HOME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\/\.local\/bin\/`);
269+
allowedPatterns.push(homePattern);
270+
}
271+
272+
// Check if path matches any allowed pattern
273+
if (!allowedPatterns.some(p => p.test(commandPath))) {
274+
return false;
275+
}
276+
277+
// Must not contain suspicious patterns
278+
if (commandPath.includes('..')) return false;
279+
if (commandPath.includes('//')) return false;
280+
281+
return true;
282+
}
283+
284+
/**
285+
* Resolve absolute path for a command using system PATH
286+
* Returns null if command not found or validation fails
287+
* Uses same PATH logic as checkCommand() for consistency
288+
*/
289+
export function resolveCommandPath(command: string): string | null {
290+
try {
291+
const result = spawnSync('which', [command], {
292+
encoding: 'utf-8',
293+
stdio: ['ignore', 'pipe', 'pipe'],
294+
timeout: 5000,
295+
env: {
296+
...process.env,
297+
PATH: buildEnhancedPath()
298+
}
299+
});
300+
301+
if (result.status === 0 && result.stdout) {
302+
const path = result.stdout.trim();
303+
if (validateCommandPath(path)) {
304+
return path;
305+
}
306+
}
307+
return null;
308+
} catch {
309+
return null;
310+
}
311+
}
312+
313+
/**
314+
* Initialize command path cache at startup
315+
* Resolves all required commands once and caches results
316+
* Called after validateSystemRuntimes() ensures they exist
317+
*
318+
* @param logger - Logger instance for logging resolved paths
319+
* @returns Map of command name to absolute path
320+
*/
321+
export function initializeCommandCache(logger: Logger): Map<string, string> {
322+
const commands = ['node', 'npm', 'npx', 'python3', 'python', 'uvx', 'uv', 'pip', 'pip3'];
323+
const cache = new Map<string, string>();
324+
325+
logger.info(
326+
{ operation: 'command_cache_init_start', commands },
327+
'Initializing command path cache...'
328+
);
329+
330+
for (const command of commands) {
331+
const path = resolveCommandPath(command);
332+
if (path) {
333+
cache.set(command, path);
334+
logger.info(
335+
{ operation: 'command_resolved', command, path },
336+
`Resolved: ${command} -> ${path}`
337+
);
338+
} else {
339+
// Log warning but don't fail - validateSystemRuntimes already checked required commands
340+
logger.info(
341+
{ operation: 'command_not_resolved', command },
342+
`Warning: Could not resolve path for ${command} (may be optional)`
343+
);
344+
}
345+
}
346+
347+
logger.info(
348+
{
349+
operation: 'command_cache_init_complete',
350+
cached_commands: cache.size,
351+
total_commands: commands.length
352+
},
353+
`Command path cache initialized with ${cache.size}/${commands.length} commands`
354+
);
355+
356+
return cache;
357+
}
358+
250359
/**
251360
* Build enhanced PATH for runtime checks
252361
* Exported for potential reuse in process spawner

0 commit comments

Comments
 (0)