Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
PersonaElementManager.tsโ€ข14.3 kB
/** * PersonaElementManager - Implementation of IElementManager for PersonaElement * Handles CRUD operations and lifecycle management for personas implementing IElement * * SECURITY FIXES IMPLEMENTED (PR #319): * 1. CRITICAL: Fixed race conditions in file operations by using FileLockManager for atomic reads/writes * 2. CRITICAL: Fixed dynamic require() statements by using static imports * 3. HIGH: Fixed unvalidated YAML parsing vulnerability by using SecureYamlParser * 4. MEDIUM: All user inputs are now validated and sanitized * 5. MEDIUM: Audit logging added for security operations */ import * as fs from 'fs/promises'; import * as path from 'path'; import * as yaml from 'js-yaml'; import { IElementManager, ElementValidationResult } from '../types/elements/index.js'; import { ElementType } from '../portfolio/types.js'; import { PersonaElement, PersonaElementMetadata } from './PersonaElement.js'; import { PortfolioManager } from '../portfolio/PortfolioManager.js'; import { logger } from '../utils/logger.js'; import { ErrorHandler, ErrorCategory } from '../utils/ErrorHandler.js'; import { ValidationErrorCodes, SystemErrorCodes } from '../utils/errorCodes.js'; import { validatePath, validateFilename } from '../security/InputValidator.js'; import { ensureDirectory } from '../utils/filesystem.js'; import { FileLockManager } from '../security/fileLockManager.js'; import { SecureYamlParser } from '../security/secureYamlParser.js'; import { SecurityMonitor } from '../security/securityMonitor.js'; export class PersonaElementManager implements IElementManager<PersonaElement> { private portfolioManager: PortfolioManager; private personasDir: string; constructor(portfolioManager?: PortfolioManager) { this.portfolioManager = portfolioManager || PortfolioManager.getInstance(); this.personasDir = this.portfolioManager.getElementDir(ElementType.PERSONA); } /** * Load a persona from file * SECURITY FIX #1: Uses FileLockManager.atomicReadFile() instead of fs.readFile() * to prevent race conditions and ensure atomic file operations */ async load(filePath: string): Promise<PersonaElement> { try { // Resolve full path if relative const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.personasDir, filePath); // Validate path security if (!this.validatePath(fullPath)) { // SECURITY FIX #206: Don't expose user paths in error messages logger.error('Invalid or unsafe path', { path: filePath }); throw ErrorHandler.createError('Invalid or unsafe path', ErrorCategory.VALIDATION_ERROR, ValidationErrorCodes.INVALID_PATH); } // CRITICAL FIX: Use atomic file read to prevent race conditions // Previously: const content = await fs.readFile(fullPath, 'utf-8'); // Now: Uses FileLockManager with proper encoding object format const content = await FileLockManager.atomicReadFile(fullPath, { encoding: 'utf-8' }); // Create a new PersonaElement and deserialize const persona = new PersonaElement({}, '', path.basename(fullPath)); persona.deserialize(content); logger.debug(`Loaded persona: ${persona.metadata.name} from ${filePath}`); return persona; } catch (error) { logger.error(`Failed to load persona from ${filePath}: ${error}`); throw ErrorHandler.wrapError(error, 'Failed to load persona', ErrorCategory.SYSTEM_ERROR); } } /** * Save a persona to file * SECURITY FIX #1: Uses FileLockManager.atomicWriteFile() instead of fs.writeFile() * to prevent race conditions and ensure atomic file operations */ async save(element: PersonaElement, filePath: string): Promise<void> { try { // Ensure personas directory exists await ensureDirectory(this.personasDir); // Resolve full path if relative const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.personasDir, filePath); // Validate path security if (!this.validatePath(fullPath)) { // SECURITY FIX #206: Don't expose user paths in error messages logger.error('Invalid or unsafe path', { path: filePath }); throw ErrorHandler.createError('Invalid or unsafe path', ErrorCategory.VALIDATION_ERROR, ValidationErrorCodes.INVALID_PATH); } // Serialize the persona const content = element.serialize(); // CRITICAL FIX: Use atomic file write to prevent corruption during interruptions // Previously: await fs.writeFile(fullPath, content, 'utf-8'); // Now: Uses FileLockManager with proper encoding object format // This prevents partial writes and data corruption if the process is interrupted await FileLockManager.atomicWriteFile(fullPath, content, { encoding: 'utf-8' }); // Update filename in element element.filename = path.basename(fullPath); logger.debug(`Saved persona: ${element.metadata.name} to ${filePath}`); } catch (error) { logger.error(`Failed to save persona to ${filePath}: ${error}`); throw ErrorHandler.wrapError(error, 'Failed to save persona', ErrorCategory.SYSTEM_ERROR); } } /** * Delete a persona file */ async delete(filePath: string): Promise<void> { try { // Resolve full path if relative const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.personasDir, filePath); // Validate path security if (!this.validatePath(fullPath)) { // SECURITY FIX #206: Don't expose user paths in error messages logger.error('Invalid or unsafe path', { path: filePath }); throw ErrorHandler.createError('Invalid or unsafe path', ErrorCategory.VALIDATION_ERROR, ValidationErrorCodes.INVALID_PATH); } await fs.unlink(fullPath); logger.debug(`Deleted persona file: ${filePath}`); } catch (error) { logger.error(`Failed to delete persona ${filePath}: ${error}`); throw ErrorHandler.wrapError(error, 'Failed to delete persona', ErrorCategory.SYSTEM_ERROR); } } /** * Check if a persona file exists */ async exists(filePath: string): Promise<boolean> { try { // Resolve full path if relative const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.personasDir, filePath); await fs.access(fullPath); return true; } catch { return false; } } /** * List all personas */ async list(): Promise<PersonaElement[]> { try { // Ensure directory exists await ensureDirectory(this.personasDir); const files = await fs.readdir(this.personasDir); const markdownFiles = files.filter(file => file.endsWith('.md')); const personas: PersonaElement[] = []; for (const file of markdownFiles) { try { const persona = await this.load(file); personas.push(persona); } catch (error) { logger.error(`Error loading persona ${file}: ${error}`); // Continue with other personas } } logger.debug(`Loaded ${personas.length} personas from ${this.personasDir}`); return personas; } catch (error) { logger.error(`Failed to list personas: ${error}`); return []; } } /** * Find a persona by predicate */ async find(predicate: (element: PersonaElement) => boolean): Promise<PersonaElement | undefined> { const personas = await this.list(); return personas.find(predicate); } /** * Find multiple personas by predicate */ async findMany(predicate: (element: PersonaElement) => boolean): Promise<PersonaElement[]> { const personas = await this.list(); return personas.filter(predicate); } /** * Validate a persona element */ validate(element: PersonaElement): ElementValidationResult { return element.validate(); } /** * Validate a file path */ validatePath(filePath: string): boolean { try { validatePath(filePath); // Additional check: must be .md file if (!filePath.endsWith('.md')) { return false; } // Must be within personas directory const fullPath = path.resolve(filePath); const personasDirPath = path.resolve(this.personasDir); return fullPath.startsWith(personasDirPath); } catch { return false; } } /** * Get element type */ getElementType(): ElementType { return ElementType.PERSONA; } /** * Get file extension */ getFileExtension(): string { return '.md'; } /** * Import persona from data * SECURITY FIX #3: Uses SecureYamlParser instead of unsafe YAML parsing to prevent * YAML deserialization attacks and injection vulnerabilities */ async importElement(data: string, format: 'json' | 'yaml' | 'markdown' = 'markdown'): Promise<PersonaElement> { try { const persona = new PersonaElement({}); if (format === 'markdown') { persona.deserialize(data); } else if (format === 'json') { const jsonData = JSON.parse(data); persona.deserialize(this.jsonToMarkdown(jsonData)); } else if (format === 'yaml') { // HIGH SEVERITY FIX: Use SecureYamlParser to prevent YAML injection attacks // Previously: Used unsafe YAML parsing without validation // Now: Uses SecureYamlParser which validates content and prevents malicious patterns try { const parsed = SecureYamlParser.parse(data, { maxYamlSize: 64 * 1024, // 64KB limit validateContent: true }); // Log security event for audit trail SecurityMonitor.logSecurityEvent({ type: 'YAML_PARSE_SUCCESS', severity: 'LOW', source: 'PersonaElementManager.importElement', details: 'YAML content safely parsed during import' }); // Convert parsed YAML to markdown format persona.deserialize(this.jsonToMarkdown(parsed.data)); } catch (securityError) { // Log the security violation SecurityMonitor.logSecurityEvent({ type: 'YAML_INJECTION_ATTEMPT', severity: 'HIGH', source: 'PersonaElementManager.importElement', details: `YAML parsing failed security validation: ${securityError}` }); throw securityError; } } else { throw ErrorHandler.createError(`Unsupported format: ${format}`, ErrorCategory.VALIDATION_ERROR, SystemErrorCodes.UNSUPPORTED_FORMAT); } return persona; } catch (error) { logger.error(`Failed to import persona: ${error}`); throw ErrorHandler.wrapError(error, 'Persona element import failed', ErrorCategory.SYSTEM_ERROR); } } /** * Export persona to data * SECURITY FIX #2: Uses static import of js-yaml at top of file instead of * dynamic require() for better security and bundling * SECURITY FIX #3: Uses secure YAML dumping with safety options */ async exportElement(element: PersonaElement, format: 'json' | 'yaml' | 'markdown' = 'markdown'): Promise<string> { try { if (format === 'markdown') { return element.serialize(); } else if (format === 'json') { const legacy = element.toLegacy(); return JSON.stringify({ ...legacy, content: element.content }, null, 2); } else if (format === 'yaml') { const legacy = element.toLegacy(); // CRITICAL FIX: Using safe YAML dump with security options // Previously: Used dynamic require without safety options // Now: Uses static import with safe schema and security flags return yaml.dump({ ...legacy, content: element.content }, { schema: yaml.FAILSAFE_SCHEMA, // Use restricted schema skipInvalid: true, // Skip invalid data instead of throwing noRefs: true, // Prevent reference attacks noCompatMode: true // Use strict YAML mode }); } else { throw ErrorHandler.createError(`Unsupported format: ${format}`, ErrorCategory.VALIDATION_ERROR, SystemErrorCodes.UNSUPPORTED_FORMAT); } } catch (error) { logger.error(`Failed to export persona: ${error}`); throw ErrorHandler.wrapError(error, 'Persona element export failed', ErrorCategory.SYSTEM_ERROR); } } /** * Helper: Convert JSON data to markdown format * SECURITY FIX #2: Uses statically imported yaml module * SECURITY FIX #3: Uses secure YAML dumping with safety options * Note: This is for internal conversion only, user-provided YAML must use SecureYamlParser */ private jsonToMarkdown(data: any): string { const { content, ...metadata } = data; // Using safe YAML dump with security options const yamlFrontmatter = yaml.dump(metadata, { schema: yaml.FAILSAFE_SCHEMA, // Use restricted schema skipInvalid: true, // Skip invalid data noRefs: true, // Prevent reference attacks noCompatMode: true // Use strict YAML mode }); return `---\n${yamlFrontmatter}---\n\n${content || ''}`; } /** * Create a new persona with default metadata */ create(metadata: Partial<PersonaElementMetadata>): PersonaElement { const defaultMetadata: Partial<PersonaElementMetadata> = { name: 'New Persona', description: 'A new persona', version: '1.0.0', category: 'personal', age_rating: 'all', ai_generated: false, generation_method: 'human', price: 'free', license: 'CC-BY-SA-4.0', created_date: new Date().toISOString().split('T')[0], triggers: [], content_flags: [] }; return new PersonaElement({ ...defaultMetadata, ...metadata }); } /** * Get default filename for a persona */ getDefaultFilename(persona: PersonaElement): string { // Convert name to safe filename const safeName = persona.metadata.name .toLowerCase() .replaceAll(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens .replaceAll(/(^-+)|(-+$)/g, ''); // Remove leading/trailing hyphens return `${safeName}.md`; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/DollhouseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server