import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { Logger } from '../utils/logger.js';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as yaml from 'js-yaml';
import { LudusCliWrapper } from '../ludusMCP/cliWrapper.js';
import Ajv from 'ajv';
// Base directory for all Ludus MCP operations
const LUDUS_MCP_BASE_DIR = path.join(os.homedir(), '.ludus-mcp');
const RANGE_CONFIG_TEMPLATES_DIR = path.join(LUDUS_MCP_BASE_DIR, 'range-config-templates');
/**
* Resolve a file path for range configurations with security validation
* SECURITY: Only allows access within ~/.ludus-mcp/range-config-templates/ directory
*
* BLOCKED PATTERNS:
* - Absolute paths: "/etc/passwd", "C:\Windows\System32"
* - Path traversal: "../../../etc/passwd", "~/.ssh/authorized_keys"
* - Invalid characters: ";", "&", "|", "`", "$", etc.
* - Wrong extensions: ".exe", ".sh", ".bat" (only .yml, .yaml, .json allowed)
*
* ALLOWED PATTERNS:
* - Simple filenames: "my-config.yml"
* - Subdirectories: "base-configs/acme.yml", "user1/config.yaml"
* - Safe characters: alphanumeric, ".", "_", "-", "/"
*/
function resolveConfigPath(filePath: string, user?: string): string {
// SECURITY: Block absolute paths completely
if (path.isAbsolute(filePath)) {
throw new Error('Absolute file paths are not allowed for security reasons. Use relative paths only.');
}
// SECURITY: Block path traversal attempts
if (filePath.includes('..') || filePath.includes('~')) {
throw new Error('Path traversal patterns (../, ~) are not allowed for security reasons. Use relative paths only (e.g., "base-configs", "user1/config.yml").');
}
// SECURITY: Block Windows and Unix path separators at start
if (filePath.startsWith('/') || filePath.startsWith('\\')) {
throw new Error('Paths cannot start with / or \\ for security reasons.');
}
// SECURITY: Validate file path characters (only allow safe characters)
const safePathRegex = /^[a-zA-Z0-9._/-]+$/;
if (!safePathRegex.test(filePath)) {
throw new Error('File path contains invalid characters. Only alphanumeric, dots, dashes, underscores, and forward slashes are allowed.');
}
// SECURITY: Validate file extension (only allow config file extensions)
const allowedExtensions = ['.yml', '.yaml', '.json'];
const fileExtension = path.extname(filePath).toLowerCase();
if (fileExtension && !allowedExtensions.includes(fileExtension)) {
throw new Error(`File extension '${fileExtension}' is not allowed. Only .yml, .yaml, and .json files are supported.`);
}
let resolvedPath: string;
// If it's just a filename (no directory separators)
if (path.dirname(filePath) === '.') {
if (user) {
// User specified: put in user-specific subdirectory under range-config-templates
const userTemplatesDir = path.join(RANGE_CONFIG_TEMPLATES_DIR, user);
resolvedPath = path.join(userTemplatesDir, filePath);
} else {
// No user specified: put in base range-config-templates directory
resolvedPath = path.join(RANGE_CONFIG_TEMPLATES_DIR, filePath);
}
} else {
// If it's a relative path with directories, resolve relative to range-config-templates
resolvedPath = path.join(RANGE_CONFIG_TEMPLATES_DIR, filePath);
}
// SECURITY: Final check - ensure resolved path is within allowed directory
const normalizedResolved = path.normalize(resolvedPath);
const normalizedAllowed = path.normalize(RANGE_CONFIG_TEMPLATES_DIR);
if (!normalizedResolved.startsWith(normalizedAllowed + path.sep) && normalizedResolved !== normalizedAllowed) {
throw new Error('Resolved file path is outside the allowed directory for security reasons.');
}
return normalizedResolved;
}
/**
* Ensure the base directory and range-config structure exist
*/
function ensureBaseDirExists(): void {
if (!fs.existsSync(LUDUS_MCP_BASE_DIR)) {
fs.mkdirSync(LUDUS_MCP_BASE_DIR, { recursive: true });
}
// Ensure range-config-templates directory exists (for files without user)
if (!fs.existsSync(RANGE_CONFIG_TEMPLATES_DIR)) {
fs.mkdirSync(RANGE_CONFIG_TEMPLATES_DIR, { recursive: true });
}
}
/**
* Ensure user-specific range config directory exists
*/
function ensureUserRangeConfigDir(user?: string): string {
const currentUser = user || 'current-user';
const userTemplatesDir = path.join(RANGE_CONFIG_TEMPLATES_DIR, currentUser);
if (!fs.existsSync(userTemplatesDir)) {
fs.mkdirSync(userTemplatesDir, { recursive: true });
}
return userTemplatesDir;
}
// Basic Ludus range schema validation
interface LudusRange {
ludus?: {
cloud?: {
proxmox?: {
nodes?: Array<{
hostname: string;
ip: string;
username: string;
ssh_key: string;
}>;
};
};
};
vms?: Array<{
hostname: string;
template?: string;
vlan?: number;
ip_last_octet?: number;
ram_gb?: number;
cpus?: number;
linux?: boolean;
testing?: {
snapshot?: boolean;
block_internet?: boolean;
};
roles?: string[];
}>;
}
interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
export interface ReadRangeConfigArgs {
source: string; // file path or URL
}
export interface WriteRangeConfigArgs {
content: string; // YAML content
filePath: string; // where to save
user?: string; // user directory to organize configs (optional, defaults to 'current-user')
}
export interface ValidateRangeConfigArgs {
source?: string; // file path or URL
content?: string; // inline YAML content
}
export interface ListRangeConfigsArgs {
directory?: string; // directory path to search (optional - uses smart defaults)
recursive?: boolean; // whether to search subdirectories (default: true for smart defaults)
}
export interface GetRangeConfigArgs {
example?: boolean; // get example config instead of current config
user?: string; // user to get config for (admin only)
}
/**
* Get allowed base paths for security validation
* Only allows access to range-config-templates directory
*/
function getAllowedBasePaths(): string[] {
const allowedPaths = [];
// Only allow range-config-templates directory and subdirectories
const rangeConfigTemplatesDir = path.join(os.homedir(), '.ludus-mcp', 'range-config-templates');
allowedPaths.push(path.resolve(rangeConfigTemplatesDir));
return allowedPaths;
}
/**
* Get default directories to search for range configurations
* Restricts search to range-config-templates directory only
*/
function getDefaultSearchDirectories(): string[] {
const searchDirs = [];
// Primary search location: ~/.ludus-mcp/range-config-templates/ and subdirectories
const rangeConfigTemplatesDir = path.join(os.homedir(), '.ludus-mcp', 'range-config-templates');
searchDirs.push(rangeConfigTemplatesDir);
return searchDirs;
}
/**
* Validate that a directory path is within allowed security boundaries
*/
function validateDirectoryPath(requestedPath: string): { valid: boolean; error?: string } {
try {
const resolvedPath = path.resolve(requestedPath);
const allowedPaths = getAllowedBasePaths();
// Check if the resolved path is within any of the allowed base paths
const isAllowed = allowedPaths.some(basePath => {
const relativePath = path.relative(basePath, resolvedPath);
// If relative path doesn't start with '..', it's within the base path
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
});
if (!isAllowed) {
return {
valid: false,
error: `Access denied: Directory must be within MCP root (${process.cwd()}) or ~/.ludus-mcp/`
};
}
return { valid: true };
} catch (error) {
return {
valid: false,
error: `Invalid path: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Recursively find all YAML files in a directory
*/
function findYamlFilesRecursively(directory: string, recursive: boolean = false): string[] {
const yamlFiles: string[] = [];
try {
const files = fs.readdirSync(directory);
for (const file of files) {
const filePath = path.join(directory, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory() && recursive) {
// Recursively search subdirectories
yamlFiles.push(...findYamlFilesRecursively(filePath, recursive));
} else if (stats.isFile() && (file.toLowerCase().endsWith('.yml') || file.toLowerCase().endsWith('.yaml'))) {
yamlFiles.push(filePath);
}
}
} catch (error) {
// Ignore directories we can't read
}
return yamlFiles;
}
// Cache for the official Ludus schema
let cachedLudusSchema: any = null;
/**
* Fetch the official Ludus range configuration schema
*/
async function fetchLudusSchema(logger: Logger): Promise<any> {
if (cachedLudusSchema) {
return cachedLudusSchema;
}
const schemaUrl = 'https://docs.ludus.cloud/schemas/range-config.json';
logger.info('Fetching official Ludus schema', { url: schemaUrl });
try {
const response = await fetch(schemaUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
cachedLudusSchema = await response.json();
logger.info('Successfully cached Ludus schema', {
schemaProperties: Object.keys(cachedLudusSchema.properties || {}).length,
hasMetaSchema: !!cachedLudusSchema.$schema,
schemaTitle: cachedLudusSchema.title || 'Unknown'
});
return cachedLudusSchema;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Failed to fetch Ludus schema', { error: errorMessage });
throw new Error(`Failed to fetch official Ludus schema: ${errorMessage}`);
}
}
/**
* Validate Ludus range configuration against official schema
*/
export async function validateLudusRangeSchema(config: any, logger: Logger): Promise<ValidationResult> {
const result: ValidationResult = {
valid: true,
errors: [],
warnings: []
};
try {
// Fetch the official schema
const rawSchema = await fetchLudusSchema(logger);
// Remove the $schema property that causes AJV issues with meta-schema resolution
const schema = { ...rawSchema };
delete schema.$schema;
// Create AJV validator instance
const ajv = new Ajv({
allErrors: true, // Report all errors, not just the first one
verbose: true, // Include schema and data in errors
strict: false, // Allow unknown keywords in schema
validateFormats: false // Skip format validation for better compatibility
});
// Compile the schema
const validate = ajv.compile(schema);
// Validate the config
const isValid = validate(config);
if (!isValid && validate.errors) {
result.valid = false;
// Convert AJV errors to readable format
validate.errors.forEach(error => {
const path = error.instancePath ? `at ${error.instancePath}` : 'at root';
const message = error.message || 'validation failed';
if (error.keyword === 'required') {
result.errors.push(`Missing required property ${path}: ${error.params?.missingProperty || 'unknown property'}`);
} else if (error.keyword === 'type') {
result.errors.push(`Invalid type ${path}: expected ${error.params?.type}, got ${typeof error.data}`);
} else if (error.keyword === 'enum') {
result.errors.push(`Invalid value ${path}: must be one of [${error.params?.allowedValues?.join(', ') || 'see schema'}]`);
} else if (error.keyword === 'additionalProperties') {
result.errors.push(`Unknown property ${path}: ${error.params?.additionalProperty} is not allowed`);
} else {
result.errors.push(`Schema violation ${path}: ${message}`);
}
});
} else {
logger.info('Configuration passed official Ludus schema validation');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Schema validation failed', { error: errorMessage });
result.valid = false;
result.errors.push(`Schema validation error: ${errorMessage}`);
}
return result;
}
/**
* Read config content from local file or URL with security validation
*/
async function readConfigContent(source: string, logger: Logger): Promise<string> {
logger.info('Reading config from source', { source });
// Check if it's a URL
if (source.startsWith('http://') || source.startsWith('https://')) {
logger.info('Fetching config from URL', { url: source });
try {
const response = await fetch(source);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const content = await response.text();
logger.info('Successfully fetched config from URL', {
url: source,
contentLength: content.length
});
return content;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Failed to fetch config from URL', { url: source, error: errorMessage });
throw new Error(`Failed to fetch from URL: ${errorMessage}`);
}
} else {
// Local file - validate security boundaries first
ensureBaseDirExists();
const resolvedPath = resolveConfigPath(source);
// Security validation: ensure the resolved path is within allowed boundaries
const allowedPaths = getAllowedBasePaths();
const isAllowed = allowedPaths.some(basePath => {
const relative = path.relative(basePath, resolvedPath);
return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
});
if (!isAllowed) {
const errorMessage = `Access denied: file path '${resolvedPath}' is outside allowed directories. Allowed paths: ${allowedPaths.join(', ')}`;
logger.error('Security validation failed for file access', {
source,
resolvedPath,
allowedPaths
});
throw new Error(errorMessage);
}
logger.info('Reading config from local file', {
originalPath: source,
resolvedPath: resolvedPath
});
try {
if (!fs.existsSync(resolvedPath)) {
throw new Error(`File does not exist: ${resolvedPath}`);
}
const content = fs.readFileSync(resolvedPath, 'utf8');
logger.info('Successfully read config from file', {
originalPath: source,
resolvedPath: resolvedPath,
contentLength: content.length
});
return content;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Failed to read config from file', {
originalPath: source,
resolvedPath: resolvedPath,
error: errorMessage
});
throw new Error(`Failed to read file: ${errorMessage}`);
}
}
}
/**
* Create read_range_config tool
*/
export function createReadRangeConfigTool(logger: Logger, cliWrapper: LudusCliWrapper): Tool {
return {
name: 'read_range_config',
description: `Read and return the content of a Ludus range configuration file from local filesystem or remote URL. Does not perform validation - use for examining configs.
**WORKING DIRECTORY**: All local file paths are relative to ~/.ludus-mcp/range-config-templates/
**PATH USAGE**:
- Use relative paths only: "base-configs/acme.yml", "user1/config.yaml", "mp/settings.yml"
- DO NOT use: "~/.ludus-mcp/...", "/absolute/paths", or "../traversal"
- URLs (http/https) are allowed for remote configs
**EXAMPLES**:
- source: "base-configs/acme.yml" → reads ~/.ludus-mcp/range-config-templates/base-configs/acme.yml
- source: "user1/config.yaml" → reads ~/.ludus-mcp/range-config-templates/user1/config.yaml
- source: "https://example.com/config.yml" → reads from URL
CRITICAL LLM BEHAVIOR: CREDENTIAL REDACTION REQUIRED
When displaying configuration content in conversation, you MUST replace any non-range-specific credentials such as API keys for external services, passwords not specific to the cyber range environment, or similar credentials with "REDACTED-CREDENTIAL" to prevent credential exposure in chat logs.`,
inputSchema: {
type: 'object',
properties: {
source: {
type: 'string',
description: 'Relative file path (e.g., "base-configs/acme.yml") or URL (http/https) of the range configuration to read. Local files must be within ~/.ludus-mcp/range-config-templates/'
}
},
required: ['source']
}
};
}
/**
* Create write_range_config tool
*/
export function createWriteRangeConfigTool(logger: Logger, cliWrapper: LudusCliWrapper): Tool {
return {
name: 'write_range_config',
description: `Write range configuration content to a local file with automatic schema validation. The config will be validated against Ludus range schema before being saved.
**FILE PATH REQUIREMENTS**:
- Use relative paths only (e.g., "base-configs/acme.yml", "user1/config.yaml")
- All files must be within ~/.ludus-mcp/range-config-templates/ directory
- Only .yml, .yaml, and .json extensions allowed
- Safe characters only: alphanumeric, dots, dashes, underscores, forward slashes
CRITICAL SECURITY WARNING - CREDENTIAL HANDLING RULES
**EXTERNAL SERVICE CREDENTIALS** (use placeholders): API keys for Tailscale/cloud services, SaaS tokens, external database connections
- Format: {{LudusCredName-<targetLudusUser>-<CredentialName>}}
- Examples: {{LudusCredName-MP-TailscaleKey}}, {{LudusCredName-Admin-APIToken}}
**RANGE-INTERNAL CREDENTIALS** (include directly): Active Directory passwords, SCCM passwords, domain passwords, local Windows/Linux accounts, internal database passwords
- Format: Direct inclusion in YAML (e.g., "ClientPushPassword": "P@ssw0rd123!")
- Examples: Active Directory admin passwords, SCCM service account passwords, domain controller passwords, local SQL passwords, Windows service account passwords
CRITICAL LLM BEHAVIOR: CREDENTIAL REDACTION REQUIRED
When displaying configuration content in conversation, you MUST replace any external service credentials (API keys, SaaS tokens) with "REDACTED-CREDENTIAL" but can show range-internal passwords directly as they are part of the lab environment.
IMPORTANT LLM BEHAVIORAL PROMPTS:
- SAFETY FIRST: Ludus operations can be destructive and time-consuming
- CHECK EXISTING STATE: Use read_range_config to check if file exists before overwriting
- Configuration changes overwrite existing settings - warn users about data loss
- Always validate configurations before writing to prevent deployment failures
- NEVER include actual API keys, passwords, or tokens in config content
- Use credential placeholders: {{LudusCredName-<targetUser>-<credName>}}
- REDACT credentials when showing config content to users
CRITICAL USER PARAMETER USAGE:
- EXTRACT USER INTENT: When user says "for [username]", "create for [username]", "[username] user", etc. → set user parameter
- USER-SPECIFIC CONFIGS: Use user parameter when creating configs for specific users or when organizing by user
- GENERAL TEMPLATES: Omit user parameter for general-purpose templates or base configurations
EXAMPLES:
- "Create SCCM config for TestRange user" → user: "TestRange"
- "Make this template for admin" → user: "admin"
- "Generate config for MP user" → user: "MP"
- "Create a basic template" → user: undefined (goes to base templates)
- "Save this config" → user: undefined (unless context indicates specific user)
CRITICAL WORKFLOW REMINDER:
- After writing a config file, you must use set_range_config to make it the active configuration
- Only THEN can you deploy_range - deployment uses the currently SET config, not just any file
- Typical workflow: write_range_config → validate_range_config → set_range_config → deploy_range`,
inputSchema: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'YAML content of the range configuration to write. CRITICAL: DO NOT include actual credentials! Use placeholders like {{LudusCredName-<targetUser>-<credName>}} instead.'
},
filePath: {
type: 'string',
description: 'Relative file path where to save the configuration (e.g., "base-configs/acme.yml", "user1/config.yaml"). Only .yml, .yaml, .json extensions allowed. Files saved within ~/.ludus-mcp/range-config-templates/ directory.'
},
user: {
type: 'string',
description: 'User directory to organize configs under (EXTRACT FROM USER INTENT). When user mentions "for [username]", "create for [username]", "[username] user", etc., use this parameter. If specified, files will be saved in ~/.ludus-mcp/range-config-templates/<user>/. If not specified, files will be saved in ~/.ludus-mcp/range-config-templates/. Examples: "TestRange", "admin", "MP"'
}
},
required: ['content', 'filePath']
}
};
}
/**
* Create validate_range_config tool
*/
export function createValidateRangeConfigTool(logger: Logger, cliWrapper: LudusCliWrapper): Tool {
return {
name: 'validate_range_config',
description: `Validate a Ludus range configuration against the schema. Can validate from file path, URL, or inline content.
CREDENTIAL SECURITY REMINDER
If validating inline content, DO NOT include non-range-specific credentials such as API keys for external services, passwords not specific to the cyber range environment, or similar!
Use placeholders: {{LudusCredName-<targetUser>-<credName>}}
CRITICAL LLM BEHAVIOR: CREDENTIAL REDACTION REQUIRED
When displaying configuration content in conversation, you MUST replace any non-range-specific credentials such as API keys for external services, passwords not specific to the cyber range environment, or similar credentials with "REDACTED-CREDENTIAL" to prevent credential exposure in chat logs.
CRITICAL WORKFLOW REMINDER:
- After validating a config file, you must use set_range_config to make it the active configuration
- Only THEN can you deploy_range - deployment uses the currently SET config, not just any file
- Typical workflow: write_range_config → validate_range_config → set_range_config → deploy_range`,
inputSchema: {
type: 'object',
properties: {
source: {
type: 'string',
description: 'File path (relative or absolute) or URL (http/https) of the range configuration to validate'
},
content: {
type: 'string',
description: 'Inline YAML content to validate (alternative to source). CRITICAL: DO NOT include actual credentials! Use placeholders like {{LudusCredName-<targetUser>-<credName>}}.'
}
},
oneOf: [
{ required: ['source'] },
{ required: ['content'] }
]
}
};
}
/**
* Create list_range_configs tool
*/
export function createListRangeConfigsTool(logger: Logger, cliWrapper: LudusCliWrapper): Tool {
return {
name: 'list_range_configs',
description: `List range configuration files within the Ludus MCP templates directory. Shows file names, sizes, and validation status.
**WORKING DIRECTORY**: All paths are relative to ~/.ludus-mcp/range-config-templates/
**PATH USAGE**:
- Use relative paths only: "base-configs", "user1", "mp/configs"
- DO NOT use: "~/.ludus-mcp/...", "/absolute/paths", or "../traversal"
- If no directory specified, automatically searches all templates recursively
**FILE RESTRICTIONS**:
- Only searches .yml, .yaml, and .json files
- All directories must be within the templates directory for security
**EXAMPLES**:
- directory: "base-configs" → searches ~/.ludus-mcp/range-config-templates/base-configs/
- directory: "user1" → searches ~/.ludus-mcp/range-config-templates/user1/
- (no directory) → searches all of ~/.ludus-mcp/range-config-templates/
CRITICAL WORKFLOW REMINDER:
- To deploy with a specific config file, you must first use set_range_config to make it active
- deploy_range uses the currently SET config, not just any config file
- Typical workflow: choose config → set_range_config → deploy_range`,
inputSchema: {
type: 'object',
properties: {
directory: {
type: 'string',
description: 'Relative directory path within ~/.ludus-mcp/range-config-templates/ to search (e.g., "base-configs", "user1", "mp/configs"). Do NOT include "~/.ludus-mcp" or absolute paths. If omitted, searches all templates automatically.'
},
recursive: {
type: 'boolean',
description: 'Whether to search subdirectories recursively. Default: true for smart search, false for specific directory.',
default: false
}
},
required: []
}
};
}
/**
* Create get_range_config tool
*/
export function createGetRangeConfigTool(logger: Logger, cliWrapper: LudusCliWrapper): Tool {
return {
name: 'get_range_config',
description: `Get the current range configuration for a user, or get an example configuration.
CRITICAL LLM BEHAVIOR: CREDENTIAL REDACTION REQUIRED
When displaying configuration content in conversation, you MUST replace any non-range-specific credentials such as API keys for external services, passwords not specific to the cyber range environment, or similar credentials with "REDACTED-CREDENTIAL" to prevent credential exposure in chat logs.
IMPORTANT LLM BEHAVIORAL PROMPTS:
- SAFETY FIRST: Ludus operations can be destructive and time-consuming
- CHECK EXISTING STATE: This shows current configuration state
- ADMIN vs USER: Admin operations (--user flag) affect other users' ranges - be explicit
- Use this to examine current settings before making changes
- REDACT credentials when showing config content to users`,
inputSchema: {
type: 'object',
properties: {
example: {
type: 'boolean',
description: 'Get an example range configuration instead of current user configuration',
default: false
},
user: {
type: 'string',
description: 'User ID to get configuration for (admin only). If omitted, gets configuration for current user.'
}
},
required: []
}
};
}
/**
* Handle read_range_config tool calls
*/
export async function handleReadRangeConfig(args: ReadRangeConfigArgs, logger: Logger): Promise<any> {
logger.info('Handling read_range_config request', { args });
try {
const content = await readConfigContent(args.source, logger);
return {
success: true,
source: args.source,
content: content,
contentLength: content.length,
message: `Successfully read configuration from ${args.source}`
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('read_range_config failed', { args, error: errorMessage });
return {
success: false,
source: args.source,
error: errorMessage,
message: `Failed to read configuration: ${errorMessage}`
};
}
}
/**
* Handle write_range_config tool calls
*/
export async function handleWriteRangeConfig(args: WriteRangeConfigArgs, logger: Logger): Promise<any> {
logger.info('Handling write_range_config request', {
filePath: args.filePath,
user: args.user || 'none',
contentLength: args.content.length
});
// CRITICAL SECURITY CHECK - Detect potential credential leakage
const credentialWarnings: string[] = [];
const suspiciousPatterns = [
/api[_-]?key\s*[:=]\s*['"]\s*[a-zA-Z0-9+/]{20,}['"]/i,
/token\s*[:=]\s*['"]\s*[a-zA-Z0-9+/]{20,}['"]/i,
/password\s*[:=]\s*['"]\s*[^'"\s]{8,}['"]/i,
/secret\s*[:=]\s*['"]\s*[a-zA-Z0-9+/]{20,}['"]/i,
/key\s*[:=]\s*['"]\s*[a-zA-Z0-9+/]{20,}['"]/i
];
for (const pattern of suspiciousPatterns) {
if (pattern.test(args.content)) {
credentialWarnings.push('Potential credential detected in config content');
break;
}
}
if (credentialWarnings.length > 0) {
logger.warn('POTENTIAL CREDENTIAL LEAK DETECTED', {
filePath: args.filePath,
warnings: credentialWarnings
});
}
try {
// First validate YAML syntax
let parsedConfig;
try {
parsedConfig = yaml.load(args.content);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('YAML parsing failed', { error: errorMessage });
return {
success: false,
filePath: args.filePath,
error: `Invalid YAML syntax: ${errorMessage}`,
message: 'Configuration not saved due to YAML syntax errors'
};
}
// Validate against Ludus schema
const validation = await validateLudusRangeSchema(parsedConfig, logger);
if (!validation.valid) {
logger.error('Schema validation failed', {
errors: validation.errors,
warnings: validation.warnings
});
return {
success: false,
filePath: args.filePath,
validation: validation,
error: 'Schema validation failed',
message: 'Configuration not saved due to schema validation errors'
};
}
// Ensure base directory exists and resolve file path with smart organization
ensureBaseDirExists();
const resolvedPath = resolveConfigPath(args.filePath, args.user);
// Ensure user-specific directory exists if user is specified and using filename only
if (path.dirname(args.filePath) === '.' && args.user) {
ensureUserRangeConfigDir(args.user);
}
// Create directory if it doesn't exist
const dir = path.dirname(resolvedPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
logger.info('Created directory', { directory: dir });
}
// Write file
fs.writeFileSync(resolvedPath, args.content, 'utf8');
logger.info('Successfully wrote range config', {
originalPath: args.filePath,
resolvedPath: resolvedPath,
user: args.user || 'none',
contentLength: args.content.length,
validation: validation
});
// Prepare response message with any warnings
let message = `Successfully saved configuration to ${resolvedPath}`;
// Add organization info if using filename only
if (path.dirname(args.filePath) === '.') {
if (args.user) {
message += ` (organized under user: ${args.user})`;
} else {
message += ` (saved to base templates directory)`;
}
}
if (validation.warnings.length > 0) {
message += ' (with schema warnings)';
}
if (credentialWarnings.length > 0) {
message += ' WARNING: Potential credentials detected!';
}
return {
success: true,
filePath: resolvedPath,
originalPath: args.filePath,
user: args.user || 'none',
userDirectory: path.dirname(args.filePath) === '.' && args.user ?
path.join(RANGE_CONFIG_TEMPLATES_DIR, args.user) :
(path.dirname(args.filePath) === '.' ? RANGE_CONFIG_TEMPLATES_DIR : undefined),
contentLength: args.content.length,
validation: validation,
credentialWarnings: credentialWarnings.length > 0 ? credentialWarnings : undefined,
securityReminder: credentialWarnings.length > 0 ?
'Use credential placeholders: {{LudusCredName-<targetUser>-<credName>}} instead of actual credentials!' :
undefined,
message
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('write_range_config failed', { args: { filePath: args.filePath }, error: errorMessage });
return {
success: false,
filePath: args.filePath,
error: errorMessage,
message: `Failed to write configuration: ${errorMessage}`
};
}
}
/**
* Handle validate_range_config tool calls
*/
export async function handleValidateRangeConfig(args: ValidateRangeConfigArgs, logger: Logger): Promise<any> {
logger.info('Handling validate_range_config request', { args });
try {
let content: string;
let source: string;
if (args.content) {
content = args.content;
source = 'inline content';
} else if (args.source) {
content = await readConfigContent(args.source, logger);
source = args.source;
} else {
return {
success: false,
error: 'Either source or content must be provided',
message: 'Validation failed: no input provided'
};
}
// Parse YAML
let parsedConfig;
try {
parsedConfig = yaml.load(content);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('YAML parsing failed', { error: errorMessage });
return {
success: false,
source: source,
validation: {
valid: false,
errors: [`Invalid YAML syntax: ${errorMessage}`],
warnings: []
},
message: 'Validation failed: invalid YAML syntax'
};
}
// Validate against Ludus schema
const validation = await validateLudusRangeSchema(parsedConfig, logger);
logger.info('Validation completed', {
source: source,
valid: validation.valid,
errors: validation.errors.length,
warnings: validation.warnings.length
});
return {
success: true,
source: source,
validation: validation,
message: validation.valid
? `Configuration is valid${validation.warnings.length > 0 ? ' (with warnings)' : ''}`
: 'Configuration has validation errors'
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('validate_range_config failed', { args, error: errorMessage });
return {
success: false,
source: args.source || 'inline content',
error: errorMessage,
message: `Validation failed: ${errorMessage}`
};
}
}
/**
* Handle list_range_configs tool calls
*/
export async function handleListRangeConfigs(args: ListRangeConfigsArgs, logger: Logger): Promise<any> {
// Default to recursive when using smart defaults, or use provided value
const recursive = args.recursive !== undefined ? args.recursive : (args.directory ? false : true);
// If no directory specified, use smart fallback search
if (!args.directory) {
return await handleSmartFallbackSearch(recursive, logger);
}
// Resolve directory path relative to ~/.ludus-mcp/ if provided and not absolute
ensureBaseDirExists();
const targetDirectory = resolveConfigPath(args.directory);
logger.info('Listing range configurations', {
originalDirectory: args.directory,
resolvedDirectory: targetDirectory,
recursive,
allowedPaths: getAllowedBasePaths()
});
try {
// Security: Validate directory path is within allowed boundaries
const pathValidation = validateDirectoryPath(targetDirectory);
if (!pathValidation.valid) {
return {
success: false,
directory: targetDirectory,
error: 'Security restriction',
message: pathValidation.error,
allowedPaths: getAllowedBasePaths()
};
}
// Check if directory exists
if (!fs.existsSync(targetDirectory)) {
return {
success: false,
directory: targetDirectory,
error: 'Directory does not exist',
message: `Directory not found: ${targetDirectory}`
};
}
// Check if it's actually a directory
const stats = fs.statSync(targetDirectory);
if (!stats.isDirectory()) {
return {
success: false,
directory: targetDirectory,
error: 'Path is not a directory',
message: `Path exists but is not a directory: ${targetDirectory}`
};
}
return await searchDirectoryForConfigs(targetDirectory, recursive, logger);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('list_range_configs failed', { args, error: errorMessage });
return {
success: false,
directory: targetDirectory,
recursive,
error: errorMessage,
message: `Failed to list configurations: ${errorMessage}` };
}
}
/**
* Handle get_range_config tool calls
*/
export async function handleGetRangeConfig(args: GetRangeConfigArgs, logger: Logger, cliWrapper: LudusCliWrapper): Promise<any> {
const { example = false, user } = args;
logger.info('Handling get_range_config request', { args });
try {
// Build the command arguments
const cmdArgs: string[] = ['config', 'get'];
// Add example argument if requested
if (example) {
cmdArgs.push('example');
}
// Add user flag if specified
if (user) {
cmdArgs.push('--user', user);
}
// Execute the command
const result = await cliWrapper.executeCommand('range', cmdArgs);
const targetUser = user || 'current user';
const configType = example ? 'example configuration' : `current range configuration for ${targetUser}`;
if (result.success) {
return {
success: true,
user: targetUser,
example: example,
content: result.rawOutput || result.message,
message: `Successfully retrieved ${configType}`
};
} else {
return {
success: false,
user: targetUser,
example: example,
error: result.rawOutput || result.message,
message: `Failed to get ${configType}: ${result.rawOutput || result.message}`
};
}
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('get_range_config failed', { args, error: errorMessage });
return {
success: false,
user: user || 'current user',
example: example,
error: errorMessage,
message: `Failed to get range configuration: ${errorMessage}`
};
}
}
/**
* Smart fallback search in default directories when no directory is specified
* Now restricted to range-config-templates directory only
*/
async function handleSmartFallbackSearch(recursive: boolean, logger: Logger): Promise<any> {
logger.info('Smart search for range configurations in ~/.ludus-mcp/range-config-templates/', { recursive });
const searchDirs = getDefaultSearchDirectories();
const allConfigs = [];
const searchResults = [];
for (const searchDir of searchDirs) {
logger.debug('Searching directory', { directory: searchDir });
try {
// Security check
const pathValidation = validateDirectoryPath(searchDir);
if (!pathValidation.valid) {
logger.warn('Skipping directory due to security restriction', {
directory: searchDir,
error: pathValidation.error
});
continue;
}
// Check if directory exists
if (!fs.existsSync(searchDir)) {
logger.debug('Directory does not exist, skipping', { directory: searchDir });
searchResults.push({
directory: searchDir,
found: false,
reason: 'Directory does not exist'
});
continue;
}
const result = await searchDirectoryForConfigs(searchDir, recursive, logger);
if (result.success && result.configs.length > 0) {
// Found configs in this directory
allConfigs.push(...result.configs);
searchResults.push({
directory: searchDir,
found: true,
count: result.configs.length
});
} else {
searchResults.push({
directory: searchDir,
found: false,
reason: 'No configurations found'
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn('Error searching directory', { directory: searchDir, error: errorMessage });
searchResults.push({
directory: searchDir,
found: false,
reason: `Error: ${errorMessage}`
});
}
}
// Sort all configs by directory then relative path
allConfigs.sort((a, b) => {
const dirA = path.dirname(a.filePath);
const dirB = path.dirname(b.filePath);
if (dirA !== dirB) {
return dirA.localeCompare(dirB);
}
return a.relativePath.localeCompare(b.relativePath);
});
logger.info('Smart fallback search completed', {
totalConfigs: allConfigs.length,
searchResults
});
return {
success: true,
smartSearch: true,
searchResults,
configs: allConfigs,
totalCount: allConfigs.length,
validCount: allConfigs.filter(c => c.validation.valid).length,
message: allConfigs.length > 0
? `Found ${allConfigs.length} range configuration${allConfigs.length === 1 ? '' : 's'} across ${searchResults.filter(r => r.found).length} director${searchResults.filter(r => r.found).length === 1 ? 'y' : 'ies'}\n\n*** IMPORTANT: Use relative paths only (e.g., "base-configs/file.yml", "user1/config.yaml") - do not use ~/.ludus-mcp/ or absolute paths ***`
: `No range configurations found in any default directories\n\n*** IMPORTANT: Use relative paths only (e.g., "base-configs", "user1") - do not use ~/.ludus-mcp/ or absolute paths ***`
};
}
/**
* Search a specific directory for range configurations
*/
async function searchDirectoryForConfigs(targetDirectory: string, recursive: boolean, logger: Logger): Promise<any> {
// Find YAML files (recursively if requested)
const yamlFilePaths = findYamlFilesRecursively(targetDirectory, recursive);
if (yamlFilePaths.length === 0) {
return {
success: true,
directory: targetDirectory,
recursive,
configs: [],
totalCount: 0,
message: `No range configuration files found in ${targetDirectory}${recursive ? ' (including subdirectories)' : ''}`
};
}
// Process each YAML file
const configs = [];
for (const filePath of yamlFilePaths) {
const filename = path.basename(filePath);
const relativePath = path.relative(targetDirectory, filePath);
try {
// Get file stats
const fileStats = fs.statSync(filePath);
const sizeKB = (fileStats.size / 1024).toFixed(1);
// Read and validate the file
const content = fs.readFileSync(filePath, 'utf8');
let validation;
try {
const parsedConfig = yaml.load(content);
validation = await validateLudusRangeSchema(parsedConfig, logger);
} catch (yamlError) {
validation = {
valid: false,
errors: [`Invalid YAML syntax: ${yamlError instanceof Error ? yamlError.message : String(yamlError)}`],
warnings: []
};
}
// Determine status icon and message
let status, statusMessage;
if (validation.valid && validation.warnings.length === 0) {
status = '';
statusMessage = 'Valid';
} else if (validation.valid && validation.warnings.length > 0) {
status = '';
statusMessage = `Valid (${validation.warnings.length} warning${validation.warnings.length === 1 ? '' : 's'})`;
} else {
status = '';
statusMessage = `Invalid (${validation.errors.length} error${validation.errors.length === 1 ? '' : 's'})`;
}
configs.push({
filename,
filePath,
relativePath: recursive ? relativePath : filename, // Show relative path for recursive scans
sizeKB: `${sizeKB}KB`,
status,
statusMessage,
validation,
lastModified: fileStats.mtime.toISOString()
});
logger.debug('Processed config file', {
filePath,
valid: validation.valid,
errors: validation.errors.length,
warnings: validation.warnings.length
});
} catch (fileError) {
const errorMessage = fileError instanceof Error ? fileError.message : String(fileError);
configs.push({
filename,
filePath,
relativePath: recursive ? path.relative(targetDirectory, filePath) : filename,
sizeKB: 'N/A',
status: '💥',
statusMessage: 'Read Error',
validation: {
valid: false,
errors: [`Failed to read file: ${errorMessage}`],
warnings: []
},
lastModified: 'N/A'
});
logger.warn('Failed to process config file', { filePath, error: errorMessage });
}
}
// Sort by relative path for better organization
configs.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
logger.info('Range config search completed', {
directory: targetDirectory,
recursive,
totalFiles: yamlFilePaths.length,
validConfigs: configs.filter(c => c.validation.valid).length
});
return {
success: true,
directory: targetDirectory,
recursive,
configs,
totalCount: configs.length,
validCount: configs.filter(c => c.validation.valid).length,
message: `Found ${configs.length} range configuration${configs.length === 1 ? '' : 's'} in ${targetDirectory}${recursive ? ' (including subdirectories)' : ''}\n\n*** IMPORTANT: Use relative paths only (e.g., "base-configs/file.yml", "user1/config.yaml") - do not use ~/.ludus-mcp/ or absolute paths ***`
};
}