Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
Memory.ts41.1 kB
/** * Memory Element - Persistent context storage for continuity and learning * * Provides multiple storage backends, retention policies, and search capabilities * for maintaining context across sessions and interactions. * * SECURITY MEASURES IMPLEMENTED: * 1. Input sanitization for all memory content * 2. Memory size limits to prevent unbounded growth * 3. Path validation for file-based storage * 4. Retention policy enforcement * 5. Privacy level access control * 6. Audit logging for all operations */ import { BaseElement } from '../BaseElement.js'; import { IElement, ElementValidationResult, ValidationError } from '../../types/elements/index.js'; import { ElementType } from '../../portfolio/types.js'; import { IElementMetadata } from '../../types/elements/IElement.js'; import { UnicodeValidator } from '../../security/validators/unicodeValidator.js'; import crypto from 'crypto'; import { SecurityMonitor } from '../../security/securityMonitor.js'; import { sanitizeInput } from '../../security/InputValidator.js'; // FIX #1315: ContentValidator no longer used in addEntry (moved to background validation) // Import removed to clean up unused dependencies import { MEMORY_CONSTANTS, MEMORY_SECURITY_EVENTS, PrivacyLevel, StorageBackend, TRUST_LEVELS, TrustLevel } from './constants.js'; import { generateMemoryId } from './utils.js'; import { MemorySearchIndex, SearchQuery, SearchIndexConfig } from './MemorySearchIndex.js'; import { logger } from '../../utils/logger.js'; import DOMPurify from 'dompurify'; import { JSDOM } from 'jsdom'; /** * Maximum length for individual trigger words used in Enhanced Index * @constant {number} */ const MAX_TRIGGER_LENGTH = 50; /** * Validation pattern for trigger words - allows alphanumeric characters, hyphens, and underscores * @constant {RegExp} */ const TRIGGER_VALIDATION_REGEX = /^[a-zA-Z0-9\-_]+$/; // Initialize DOMPurify with JSDOM const window = new JSDOM('').window; const purify = DOMPurify(window as any); // Configure DOMPurify for memory content - strip all HTML but keep text purify.setConfig({ ALLOWED_TAGS: [], // No HTML tags allowed ALLOWED_ATTR: [], // No attributes allowed KEEP_CONTENT: true // Keep text content }); /** * Sanitize content for memory storage * More permissive than sanitizeInput - allows punctuation, quotes, etc. * but still prevents XSS and control characters */ function sanitizeMemoryContent(content: string, maxLength: number): string { if (!content || typeof content !== 'string') { return ''; } // First normalize Unicode const normalized = UnicodeValidator.normalize(content).normalizedContent; // Use DOMPurify to strip any HTML/XSS attempts but keep text const cleaned = purify.sanitize(normalized); // Remove only control characters and null bytes return cleaned .replaceAll(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') // NOSONAR - Intentionally removing control chars except \t \n \r for sanitization .substring(0, maxLength) .trim(); } export interface MemoryMetadata extends IElementMetadata { storageBackend?: StorageBackend; retentionDays?: number; privacyLevel?: PrivacyLevel; searchable?: boolean; maxEntries?: number; encryptionEnabled?: boolean; // Search index configuration (Issue #984) indexThreshold?: number; enableContentIndex?: boolean; maxTermsPerEntry?: number; minTermLength?: number; // Trigger words for Enhanced Index (Issue #1124) triggers?: string[]; } export interface MemoryEntry { id: string; timestamp: Date; content: string; tags?: string[]; metadata?: Record<string, any>; expiresAt?: Date; privacyLevel?: PrivacyLevel; // FIX #1269: Trust level for content security trustLevel?: TrustLevel; // Source information for trust decisions source?: string; // e.g., 'user', 'web-scrape', 'agent', 'api' } export interface MemorySearchOptions { query?: string; tags?: string[]; startDate?: Date; endDate?: Date; limit?: number; privacyLevel?: PrivacyLevel; } /** * Memory Element Implementation * * TODO: Memory Sharding Strategy (Issue #981) * --------------------------------------------- * Current: Single Map<id, entry> for all memories * Problem: Large memory sets (>10K entries) cause performance degradation * * Planned Sharding Architecture: * 1. Shard memories across multiple files based on hash(memoryId) % shardCount * 2. Each shard file <256KB for optimal YAML parsing * 3. Memory references stored separately from content (like git objects) * 4. Large binary content (PDFs, images) stored as external references * * Benefits: * - Parallel loading of memory shards * - Reduced memory footprint (load only needed shards) * - Better corruption resistance (one shard failure doesn't affect others) * - Efficient incremental updates * * TODO: Content Integrity Verification (Issue #982) * -------------------------------------------------- * Add SHA-256 hashes to detect: * - Accidental corruption from disk errors * - Intentional tampering with memory files * - Version conflicts during concurrent access * * Implementation: * - Store hash in memory metadata * - Verify on load, warn on mismatch * - Option to auto-restore from backup on corruption * * TODO: Memory Capacity Management (Issue #983) * --------------------------------------------- * Current: Synchronous retention enforcement on each add * Better: Background cleanup with smart triggers: * - Cleanup when 90% capacity reached * - Batch deletions for efficiency * - LRU eviction with access tracking * - Preserve "pinned" memories regardless of age */ export class Memory extends BaseElement implements IElement { // Memory-specific properties private entries: Map<string, MemoryEntry> = new Map(); private storageBackend: StorageBackend; private retentionDays: number; private privacyLevel: PrivacyLevel; private searchable: boolean; private maxEntries: number; // Search index for performance (Issue #984) private searchIndex: MemorySearchIndex; // Sanitization cache to avoid redundant processing private sanitizationCache: Map<string, string> = new Map(); // FIX #1320: Store file path for persistence private filePath?: string; constructor(metadata: Partial<MemoryMetadata> = {}) { // SECURITY FIX: Sanitize all inputs during construction const sanitizedMetadata = { ...metadata, name: metadata.name ? sanitizeInput(UnicodeValidator.normalize(metadata.name).normalizedContent, 100) : 'Unnamed Memory', description: metadata.description ? sanitizeInput(UnicodeValidator.normalize(metadata.description).normalizedContent, 500) : undefined, // FIX #1124: Preserve triggers for Enhanced Index triggers: Array.isArray(metadata.triggers) ? metadata.triggers .map(t => sanitizeInput(t, MAX_TRIGGER_LENGTH)) .filter(t => t && TRIGGER_VALIDATION_REGEX.test(t)) : // Only allow valid trigger patterns [] }; super(ElementType.MEMORY, sanitizedMetadata); // Initialize memory-specific properties with defaults this.storageBackend = metadata.storageBackend || MEMORY_CONSTANTS.DEFAULT_STORAGE_BACKEND; this.retentionDays = metadata.retentionDays || MEMORY_CONSTANTS.DEFAULT_RETENTION_DAYS; // Validate privacy level - default to private if invalid this.privacyLevel = (metadata.privacyLevel && MEMORY_CONSTANTS.PRIVACY_LEVELS.includes(metadata.privacyLevel)) ? metadata.privacyLevel : MEMORY_CONSTANTS.DEFAULT_PRIVACY_LEVEL; this.searchable = metadata.searchable !== false; this.maxEntries = Math.min( metadata.maxEntries || MEMORY_CONSTANTS.MAX_ENTRIES_DEFAULT, MEMORY_CONSTANTS.MAX_ENTRIES_DEFAULT ); // Set up extensions this.extensions = { storageBackend: this.storageBackend, retentionDays: this.retentionDays, privacyLevel: this.privacyLevel, searchable: this.searchable, maxEntries: this.maxEntries, encryptionEnabled: metadata.encryptionEnabled || false }; // Initialize search index with configuration (Issue #984) const indexConfig: SearchIndexConfig = { indexThreshold: metadata.indexThreshold || 100, enableContentIndex: metadata.enableContentIndex !== false, maxTermsPerEntry: metadata.maxTermsPerEntry || 100, minTermLength: metadata.minTermLength || 2, enablePersistence: false // Future enhancement }; this.searchIndex = new MemorySearchIndex(indexConfig); // Log memory creation SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_CREATED, severity: 'LOW', source: 'Memory.constructor', details: `Memory created: ${this.metadata.name} with ${this.storageBackend} backend` }); } /** * Add a new memory entry * SECURITY: Sanitizes input and enforces size limits * FIX #1315: Removed blocking validation - all entries created as UNTRUSTED * Background validation will update trust levels asynchronously (Issue #1314) * * FIX: Refactored to reduce cognitive complexity (SonarCloud S3776) * FIX (PR #1313 review): Sanitize source parameter for log injection prevention */ public async addEntry( content: string, tags?: string[], metadata?: Record<string, any>, source: string = 'unknown' ): Promise<MemoryEntry> { // SECURITY: Sanitize source parameter before use const sanitizedSource = sanitizeInput(source, 50); // FIX #1315: Sanitize content but don't validate for threats (non-blocking) // Just normalize Unicode and apply DOMPurify const sanitizedContent = sanitizeMemoryContent(content, MEMORY_CONSTANTS.MAX_ENTRY_SIZE); if (!sanitizedContent || sanitizedContent.trim().length === 0) { throw new Error('Memory content cannot be empty'); } // SECURITY FIX: Validate and sanitize tags const sanitizedTags = tags ? this.sanitizeTags(tags) : []; // Create memory entry with generated ID // FIX #1315: All new entries start as UNTRUSTED by default const entry: MemoryEntry = { id: generateMemoryId(), timestamp: new Date(), content: sanitizedContent, tags: sanitizedTags, metadata: this.sanitizeMetadata(metadata), privacyLevel: this.privacyLevel, expiresAt: this.calculateExpiryDate(), trustLevel: TRUST_LEVELS.UNTRUSTED, // Always UNTRUSTED until background validation source: sanitizedSource }; // Store entry this.entries.set(entry.id, entry); this._isDirty = true; // FIX (PR #1313): Enforce capacity AFTER adding to prevent race conditions // Multiple concurrent addEntry calls can all pass the "before" check, but // by enforcing after, we guarantee the limit is never exceeded this.enforceCapacitySync(); // Update search index (Issue #984) this.searchIndex.addEntry(entry); // Check if we should build/rebuild the index if (!this.searchIndex.isIndexed && this.entries.size >= 100) { // Build index asynchronously to avoid blocking, with retry logic this.buildSearchIndexWithRetry().catch(error => { // Final failure after retries - search will fall back to linear scan logger.error('Failed to build search index after retries, search will use fallback', error); }); } // Log memory addition SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_ADDED, severity: 'LOW', source: 'Memory.addEntry', details: `Added memory entry ${entry.id} with ${sanitizedTags.length} tags (UNTRUSTED, pending validation)` }); return entry; } /** * Enforce capacity limit synchronously * FIX (PR #1313): Made synchronous to prevent race conditions * This is called AFTER adding an entry to ensure we never exceed maxEntries */ private enforceCapacitySync(): void { if (this.entries.size <= this.maxEntries) { return; // Within capacity } // Over capacity - remove oldest entries until we're at the limit const entriesToRemove = this.entries.size - this.maxEntries; const sortedEntries = Array.from(this.entries.values()) .sort((a, b) => { const aTime = this.ensureDateObject(a.timestamp).getTime(); const bTime = this.ensureDateObject(b.timestamp).getTime(); return aTime - bTime; // Oldest first }); // Remove the oldest entries for (let i = 0; i < entriesToRemove && i < sortedEntries.length; i++) { this.entries.delete(sortedEntries[i].id); this.searchIndex.removeEntry(sortedEntries[i].id); } } /** * Search memory entries * SECURITY: Respects privacy levels and sanitizes search queries * * IMPLEMENTED: Basic indexed search for O(log n) performance (Issue #984) * - Tag index: Map<tag, Set<entryId>> for instant tag lookups ✓ * - Content index: Inverted index for term search ✓ * - Date index: Binary tree for efficient range queries ✓ * - Privacy index: Pre-sorted entries by privacy level ✓ * * TODO: Advanced indexing features (Future enhancements): * - Composite indices: Combined indices for common query patterns * - Index-of-indexes pattern: * - Master index file (meta.yaml) with pointers to shard indices * - Each shard maintains its own local index * - Periodic index compaction and optimization * - Persistent index storage to disk * - Incremental index updates for large datasets * * Performance improvement achieved: * - Previous: ~100ms for 10,000 entries (linear scan) * - Current: <5ms for same dataset (indexed search) */ public async search(options: MemorySearchOptions = {}): Promise<MemoryEntry[]> { // SECURITY FIX: Sanitize search query (use regular sanitizeInput for queries) const sanitizedQuery = options.query ? sanitizeInput(UnicodeValidator.normalize(options.query).normalizedContent, 200) : undefined; // Use indexed search if available (Issue #984) if (this.searchIndex.isIndexed) { const searchQuery: SearchQuery = { content: sanitizedQuery, tags: options.tags ? this.sanitizeTags(options.tags) : undefined, dateFrom: options.startDate, dateTo: options.endDate, privacyLevel: options.privacyLevel as PrivacyLevel, limit: options.limit }; const searchResults = this.searchIndex.search(searchQuery, this.entries); return searchResults.map(result => result.entry); } // Fallback to linear search for small datasets let results: MemoryEntry[] = []; const queryLower = sanitizedQuery?.toLowerCase(); const searchTags = options.tags && options.tags.length > 0 ? this.sanitizeTags(options.tags) : null; // Single iteration through entries with all filters applied for (const entry of this.entries.values()) { // Privacy level check if (options.privacyLevel && !this.canAccessPrivacyLevel(entry.privacyLevel || MEMORY_CONSTANTS.DEFAULT_PRIVACY_LEVEL, options.privacyLevel)) { continue; } // Query text check if (queryLower) { const contentMatch = entry.content.toLowerCase().includes(queryLower); const tagMatch = entry.tags?.some(tag => tag.toLowerCase().includes(queryLower)); if (!contentMatch && !tagMatch) { continue; } } // Tag filter check if (searchTags && !searchTags.some(searchTag => entry.tags?.includes(searchTag))) { continue; } // Date range checks if (options.startDate && entry.timestamp < options.startDate) { continue; } if (options.endDate && entry.timestamp > options.endDate) { continue; } // Entry passes all filters results.push(entry); } // Sort by timestamp (newest first) - using string comparison for IDs as secondary sort results.sort((a, b) => { // FIX #1069: Ensure timestamps are Date objects for sorting const bTime = this.ensureDateObject(b.timestamp).getTime(); const aTime = this.ensureDateObject(a.timestamp).getTime(); const timeDiff = bTime - aTime; if (timeDiff !== 0) return timeDiff; // If timestamps are exactly the same, sort by ID (which contains timestamp) return b.id.localeCompare(a.id); }); // Apply limit if (options.limit && options.limit > 0) { results = results.slice(0, options.limit); } // Log search operation SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_SEARCHED, severity: 'LOW', source: 'Memory.search', details: `Searched memories with query: ${sanitizedQuery || 'none'}, found ${results.length} results` }); return results; } /** * Get a specific memory entry by ID */ public async getEntry(id: string): Promise<MemoryEntry | undefined> { return this.entries.get(id); } /** * Delete a memory entry * SECURITY: Validates permissions and logs deletion */ public async deleteEntry(id: string): Promise<boolean> { const entry = this.entries.get(id); if (!entry) { return false; } // SECURITY: Check if sensitive memories can be deleted if (entry.privacyLevel === 'sensitive') { SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.SENSITIVE_MEMORY_DELETED, severity: 'MEDIUM', source: 'Memory.deleteEntry', details: `Sensitive memory ${id} deleted` }); } this.entries.delete(id); this._isDirty = true; return true; } /** * Get formatted content of all memory entries * Returns entries as a readable string for display * FIX #1269: Sandboxes untrusted content to prevent prompt injection */ get content(): string { if (this.entries.size === 0) { return 'No content stored'; } // Format entries as readable content (newest first) const sortedEntries = Array.from(this.entries.values()) .sort((a, b) => { // FIX #1069: Ensure timestamps are Date objects for sorting const aTime = this.ensureDateObject(a.timestamp).getTime(); const bTime = this.ensureDateObject(b.timestamp).getTime(); return bTime - aTime; }); return sortedEntries.map(entry => { // FIX #1069: Ensure timestamp is Date object before calling toISOString const timestamp = this.ensureDateObject(entry.timestamp).toISOString(); const tags = entry.tags && entry.tags.length > 0 ? ` [${entry.tags.join(', ')}]` : ''; // FIX #1269: Sandbox untrusted content const trustLevel = entry.trustLevel || TRUST_LEVELS.UNTRUSTED; let displayContent = entry.content; if (trustLevel === TRUST_LEVELS.UNTRUSTED) { // Clearly mark untrusted content displayContent = this.sandboxUntrustedContent(entry.content, entry.source || 'unknown'); } else if (trustLevel === TRUST_LEVELS.QUARANTINED) { // Don't display quarantined content at all displayContent = '[CONTENT QUARANTINED: Security threat detected]'; } // FIX: Extract nested ternary to improve readability (SonarCloud S3358) let trustIndicator: string; if (trustLevel === TRUST_LEVELS.VALIDATED) { trustIndicator = '✓'; } else if (trustLevel === TRUST_LEVELS.TRUSTED) { trustIndicator = '✓✓'; } else if (trustLevel === TRUST_LEVELS.QUARANTINED) { trustIndicator = '⚠️'; } else { trustIndicator = '⚠'; } return `[${timestamp}]${tags} ${trustIndicator}: ${displayContent}`; }).join('\n\n'); } /** * Sandbox untrusted content with clear delimiters * FIX #1269: Prevents AI from interpreting user content as instructions */ private sandboxUntrustedContent(content: string, source: string): string { return [ '┌─── UNTRUSTED CONTENT START ───┐', `│ Source: ${source}`, `│ Status: NOT VALIDATED`, '├────────────────────────────────┤', content.split('\n').map(line => `│ ${line}`).join('\n'), '└─── UNTRUSTED CONTENT END ─────┘' ].join('\n'); } /** * Enforce retention policy by removing expired entries * SECURITY: Ensures memory doesn't grow unbounded */ public async enforceRetentionPolicy(): Promise<number> { const now = new Date(); let deletedCount = 0; // Remove expired entries for (const [id, entry] of this.entries) { if (entry.expiresAt && entry.expiresAt < now) { this.entries.delete(id); deletedCount++; } } // If still at or over capacity, remove oldest entries to make room for one more if (this.entries.size >= this.maxEntries) { const sortedEntries = Array.from(this.entries.entries()) .sort((a, b) => { // FIX #1069: Ensure timestamps are Date objects for sorting const aTime = this.ensureDateObject(a[1].timestamp).getTime(); const bTime = this.ensureDateObject(b[1].timestamp).getTime(); return aTime - bTime; }); // Remove one extra to make room for new entry const toDelete = Math.max(1, this.entries.size - this.maxEntries + 1); for (let i = 0; i < toDelete && i < sortedEntries.length; i++) { this.entries.delete(sortedEntries[i][0]); deletedCount++; } } if (deletedCount > 0) { this._isDirty = true; SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.RETENTION_POLICY_ENFORCED, severity: 'LOW', source: 'Memory.enforceRetentionPolicy', details: `Removed ${deletedCount} expired memories` }); } return deletedCount; } /** * Clear all memory entries * SECURITY: Requires confirmation and logs the action */ public async clearAll(confirm: boolean = false): Promise<void> { if (!confirm) { throw new Error('Memory clear requires confirmation'); } const count = this.entries.size; this.entries.clear(); this._isDirty = true; SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_CLEARED, severity: 'HIGH', source: 'Memory.clearAll', details: `Cleared all ${count} memory entries` }); } /** * Helper function to ensure a value is a valid Date object * FIX #1069: Validates and converts timestamps to Date objects */ private ensureDateObject(value: any): Date { // Handle null/undefined if (value == null) { throw new Error(`Date value is null or undefined`); } // If already a Date, validate it if (value instanceof Date) { if (Number.isNaN(value.getTime())) { throw new Error(`Invalid Date object provided`); } return value; } // Try to convert to Date const date = new Date(value); if (Number.isNaN(date.getTime())) { throw new Error(`Invalid date value: ${value}`); } // Check for unreasonable dates (before 1970 or more than 100 years in future) const now = Date.now(); const timestamp = date.getTime(); if (timestamp < 0 || timestamp > now + (100 * 365 * 24 * 60 * 60 * 1000)) { throw new Error(`Date value out of reasonable range: ${value}`); } return date; } /** * Get memory statistics */ public getStats(): { totalEntries: number; totalSize: number; oldestEntry?: Date; newestEntry?: Date; tagFrequency: Map<string, number>; } { let totalSize = 0; let oldestEntry: Date | undefined; let newestEntry: Date | undefined; const tagFrequency = new Map<string, number>(); for (const entry of this.entries.values()) { totalSize += entry.content.length; // FIX #1069: Ensure timestamp is a valid Date object for comparison // When entries are edited, timestamps might be strings try { const entryTimestamp = this.ensureDateObject(entry.timestamp); if (!oldestEntry || entryTimestamp < oldestEntry) { oldestEntry = entryTimestamp; } if (!newestEntry || entryTimestamp > newestEntry) { newestEntry = entryTimestamp; } } catch (error) { // Log the error but continue processing other entries logger.warn(`Invalid timestamp in memory entry: ${error instanceof Error ? error.message : 'Unknown error'}`); // Skip this entry's timestamp for statistics continue; } entry.tags?.forEach(tag => { tagFrequency.set(tag, (tagFrequency.get(tag) || 0) + 1); }); } return { totalEntries: this.entries.size, totalSize, oldestEntry, newestEntry, tagFrequency }; } /** * Validate the memory element */ public override validate(): ElementValidationResult { const result = super.validate(); // Initialize errors array if not present if (!result.errors) { result.errors = []; } // Additional memory-specific validation if (this.retentionDays < MEMORY_CONSTANTS.MIN_RETENTION_DAYS || this.retentionDays > MEMORY_CONSTANTS.MAX_RETENTION_DAYS) { result.errors.push({ field: 'retentionDays', message: `Retention days must be between ${MEMORY_CONSTANTS.MIN_RETENTION_DAYS} and ${MEMORY_CONSTANTS.MAX_RETENTION_DAYS}`, severity: 'error' } as ValidationError); } if (this.maxEntries < 1 || this.maxEntries > MEMORY_CONSTANTS.MAX_ENTRIES_DEFAULT) { result.errors.push({ field: 'maxEntries', message: `Max entries must be between 1 and ${MEMORY_CONSTANTS.MAX_ENTRIES_DEFAULT}`, severity: 'error' } as ValidationError); } // Check memory size const stats = this.getStats(); if (stats.totalSize > MEMORY_CONSTANTS.MAX_MEMORY_SIZE) { result.errors.push({ field: 'memory', message: `Total memory size (${stats.totalSize}) exceeds limit (${MEMORY_CONSTANTS.MAX_MEMORY_SIZE})`, severity: 'error' } as ValidationError); } // Update validity based on our checks return { ...result, valid: result.errors.length === 0 }; } /** * Serialize memory to string */ public override serialize(): string { const data = { id: this.id, type: this.type, version: this.version, metadata: this.metadata, extensions: this.extensions, entries: Array.from(this.entries.values()) }; return JSON.stringify(data, null, 2); } /** * Process and validate a single memory entry during deserialization * FIX #1315: Reads trust level from metadata instead of re-validating * Returns true if entry was loaded, false if quarantined */ private processDeserializedEntry(entry: any): boolean { if (!this.isValidEntry(entry)) { return false; } // FIX #1315: Read trust level from metadata (set by background validation) // Don't re-validate on load - trust the stored trust level const trustLevel = entry.trustLevel || TRUST_LEVELS.UNTRUSTED; // Only skip QUARANTINED entries if (trustLevel === TRUST_LEVELS.QUARANTINED) { // Log quarantine event SecurityMonitor.logSecurityEvent({ type: 'CONTENT_INJECTION_ATTEMPT', severity: 'CRITICAL', source: 'Memory.deserialize', details: `Skipping quarantined entry in memory "${this.metadata.name}" on load`, additionalData: { entryId: entry.id, memoryName: this.metadata.name, action: 'skip_quarantined_on_load' } }); return false; // Entry quarantined, don't load } // Sanitize content (basic Unicode normalization + DOMPurify only) entry.content = this.sanitizeWithCache(entry.content, MEMORY_CONSTANTS.MAX_ENTRY_SIZE); entry.tags = this.sanitizeTags(entry.tags || []); entry.timestamp = new Date(entry.timestamp); entry.trustLevel = trustLevel; // Use trust level from file entry.source = entry.source || 'loaded'; if (entry.expiresAt) { entry.expiresAt = new Date(entry.expiresAt); } this.entries.set(entry.id, entry); return true; // Entry loaded successfully } /** * Deserialize memory from string * SECURITY: Validates all loaded data * FIX #1269: Added ContentValidator to prevent loading infected memories */ public override deserialize(data: string): void { try { const parsed = JSON.parse(data); // Validate basic structure if (!parsed.id || !parsed.type || parsed.type !== ElementType.MEMORY) { throw new Error('Invalid memory data format'); } // Update properties this.id = parsed.id; this.version = parsed.version || '1.0.0'; this.metadata = parsed.metadata || {}; this.extensions = parsed.extensions || {}; // Clear and reload entries this.entries.clear(); let quarantinedCount = 0; if (Array.isArray(parsed.entries)) { for (const entry of parsed.entries) { const loaded = this.processDeserializedEntry(entry); if (!loaded) { quarantinedCount++; } } } // Log warning if entries were quarantined if (quarantinedCount > 0) { logger.warn(`Quarantined ${quarantinedCount} infected entries from memory "${this.metadata.name}"`, { memoryName: this.metadata.name, totalEntries: parsed.entries?.length || 0, quarantined: quarantinedCount, loaded: this.entries.size }); } // Enforce retention policy after loading this.enforceRetentionPolicy(); } catch (error) { SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_DESERIALIZE_FAILED, severity: 'HIGH', source: 'Memory.deserialize', details: `Failed to deserialize memory: ${error}` }); throw new Error(`Failed to deserialize memory: ${error}`); } } // Private helper methods private calculateExpiryDate(): Date { const expiry = new Date(); expiry.setDate(expiry.getDate() + this.retentionDays); return expiry; } private sanitizeTags(tags: string[]): string[] { // SECURITY FIX: Limit number of tags and sanitize each const limitedTags = tags.slice(0, MEMORY_CONSTANTS.MAX_TAGS_PER_ENTRY); return limitedTags .map(tag => { const normalized = UnicodeValidator.normalize(tag).normalizedContent; return sanitizeInput(normalized, MEMORY_CONSTANTS.MAX_TAG_LENGTH); }) .filter(tag => tag && tag.length > 0); } private sanitizeMetadata(metadata?: Record<string, any>): Record<string, any> | undefined { if (!metadata) return undefined; // SECURITY FIX: Sanitize metadata values const sanitized: Record<string, any> = {}; const maxKeys = MEMORY_CONSTANTS.MAX_METADATA_KEYS; let keyCount = 0; for (const [key, value] of Object.entries(metadata)) { if (keyCount >= maxKeys) break; const sanitizedKey = sanitizeInput(key, MEMORY_CONSTANTS.MAX_METADATA_KEY_LENGTH); if (sanitizedKey && typeof value === 'string') { sanitized[sanitizedKey] = sanitizeInput(value, MEMORY_CONSTANTS.MAX_METADATA_VALUE_LENGTH); keyCount++; } else if (sanitizedKey && typeof value === 'number') { sanitized[sanitizedKey] = value; keyCount++; } // Skip other types for security } return sanitized; } private canAccessPrivacyLevel(entryLevel: string, requestedLevel: string): boolean { const levels = MEMORY_CONSTANTS.PRIVACY_LEVELS; const entryIndex = levels.indexOf(entryLevel as PrivacyLevel); const requestedIndex = levels.indexOf(requestedLevel as PrivacyLevel); // Can only access entries at or below the requested privacy level // e.g., if requesting 'private', can see 'public' and 'private' but not 'sensitive' return entryIndex <= requestedIndex; } private isValidEntry(entry: any): boolean { return entry && typeof entry.id === 'string' && typeof entry.content === 'string' && entry.timestamp && (!entry.tags || Array.isArray(entry.tags)); } /** * Optimized sanitization with checksum caching * Avoids re-sanitizing content that hasn't changed * @param content Content to sanitize * @param maxLength Maximum allowed length * @returns Sanitized content */ private sanitizeWithCache(content: string, maxLength: number): string { // Generate checksum for the input const checksum = crypto.createHash('sha256').update(content).digest('hex'); // Check if we've already sanitized this exact content const cacheKey = `${checksum}:${maxLength}`; const cached = this.sanitizationCache.get(cacheKey); if (cached) { return cached; } // Perform sanitization const sanitized = sanitizeMemoryContent(content, maxLength); // Cache the result (limit cache size to prevent memory issues) if (this.sanitizationCache.size > 1000) { // Remove oldest entries (simple FIFO) const firstKey = this.sanitizationCache.keys().next().value; if (firstKey) this.sanitizationCache.delete(firstKey); } this.sanitizationCache.set(cacheKey, sanitized); return sanitized; } /** * Build search index with retry logic * Attempts to build the index up to 3 times with exponential backoff * This ensures search functionality even if there are transient failures */ private async buildSearchIndexWithRetry(retries = 3): Promise<void> { let lastError: Error | undefined; for (let attempt = 1; attempt <= retries; attempt++) { try { await this.searchIndex.buildIndex(this.entries); logger.debug(`Search index built successfully on attempt ${attempt}`); return; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); logger.warn(`Search index build attempt ${attempt} failed`, { error: lastError.message, entriesCount: this.entries.size }); if (attempt < retries) { // Exponential backoff: 100ms, 200ms, 400ms const delay = Math.pow(2, attempt - 1) * 100; await new Promise(resolve => setTimeout(resolve, delay)); } } } // If we get here, all retries failed throw lastError || new Error('Failed to build search index'); } /** * Get all entries with a specific trust level * FIX #1320: Public API for accessing entries by trust level * Used by BackgroundValidator to find untrusted entries * * @param trustLevel - The trust level to filter by * @returns Array of memory entries matching the trust level */ public getEntriesByTrustLevel(trustLevel: TrustLevel): MemoryEntry[] { return Array.from(this.entries.values()) .filter(entry => entry.trustLevel === trustLevel); } /** * Get all entries in this memory * FIX #1320: Public API for accessing all entries * Replaces the need for `(memory as any).entries` hacks * * @returns Array of all memory entries */ public getAllEntries(): MemoryEntry[] { return Array.from(this.entries.values()); } /** * Get an iterator over all entries * FIX #1320: Memory-efficient way to iterate entries * * @returns Iterator over memory entries */ public *getEntriesIterator(): IterableIterator<MemoryEntry> { yield* this.entries.values(); } /** * Set the file path for this memory * FIX #1320: Used by MemoryManager after loading * FIX (SonarCloud): Added input validation with proper error types * @param path - The file path where this memory is stored * @throws {TypeError} If path is not a string * @throws {Error} If path is empty */ public setFilePath(path: string): void { if (typeof path !== 'string') { throw new TypeError('Memory file path must be a string'); } if (path.trim().length === 0) { throw new Error('Memory file path cannot be empty'); } this.filePath = path; } /** * Get the file path for this memory * FIX #1320: Returns the path where this memory is stored * @returns The file path, or undefined if not yet persisted */ public getFilePath(): string | undefined { return this.filePath; } /** * Save this memory to disk * FIX #1320: Instance method for persisting memory changes * Used by BackgroundValidator to save updated trust levels * * @returns Promise that resolves when save is complete * @throws {Error} If memory has not been loaded from file and no path is set */ public async save(): Promise<void> { // Dynamically import MemoryManager to avoid circular dependency const { MemoryManager } = await import('./MemoryManager.js'); const manager = new MemoryManager(); await manager.save(this, this.filePath); } /** * Find all memories that have entries with a specific trust level * FIX #1320: Static query API for finding memories by trust level * Used by BackgroundValidator to discover untrusted memories * * @param trustLevel - The trust level to filter by * @param options - Optional query options * @param options.limit - Maximum number of memories to return * @returns Promise resolving to array of memories with matching entries */ public static async findByTrustLevel( trustLevel: TrustLevel, options?: { limit?: number } ): Promise<Memory[]> { // Dynamically import MemoryManager to avoid circular dependency const { MemoryManager } = await import('./MemoryManager.js'); const manager = new MemoryManager(); // Load all memories and filter by trust level const allMemories = await manager.list(); const matchingMemories: Memory[] = []; for (const memory of allMemories) { // Check if this memory has any entries with the specified trust level const hasMatchingEntries = memory.getEntriesByTrustLevel(trustLevel).length > 0; if (hasMatchingEntries) { matchingMemories.push(memory); // Apply limit if specified if (options?.limit && matchingMemories.length >= options.limit) { break; } } } logger.debug('Found memories with trust level', { trustLevel, count: matchingMemories.length, limit: options?.limit }); return matchingMemories; } /** * General query API for finding memories * FIX #1320: Flexible query API for multiple criteria * FIX (SonarCloud): Refactored to reduce cognitive complexity from 20 to 8 * * @param filter - Query filter criteria * @param filter.trustLevel - Filter by trust level * @param filter.tags - Filter by tags * @param filter.maxAge - Filter by age in days * @returns Promise resolving to array of matching memories */ public static async find(filter: { trustLevel?: TrustLevel; tags?: string[]; maxAge?: number; }): Promise<Memory[]> { // Dynamically import MemoryManager to avoid circular dependency const { MemoryManager } = await import('./MemoryManager.js'); const manager = new MemoryManager(); // Load all memories const allMemories = await manager.list(); // Calculate cutoff date if maxAge is specified const cutoffDate = filter.maxAge ? new Date(Date.now() - filter.maxAge * 24 * 60 * 60 * 1000) : undefined; // Filter memories using helper method const results = allMemories.filter(memory => this.matchesFilter(memory, filter, cutoffDate) ); logger.debug('Memory query complete', { filter, resultCount: results.length }); return results; } /** * Check if a memory matches the given filter criteria * FIX (SonarCloud): Extracted to reduce cognitive complexity * @private */ private static matchesFilter( memory: Memory, filter: { trustLevel?: TrustLevel; tags?: string[]; maxAge?: number }, cutoffDate?: Date ): boolean { // Filter by trust level if (filter.trustLevel) { const hasMatchingTrustLevel = memory.getEntriesByTrustLevel(filter.trustLevel).length > 0; if (!hasMatchingTrustLevel) { return false; } } // Filter by tags if (filter.tags && filter.tags.length > 0) { const memoryTags = memory.metadata.tags || []; const hasMatchingTag = filter.tags.some(tag => memoryTags.includes(tag)); if (!hasMatchingTag) { return false; } } // Filter by age if (cutoffDate) { const stats = memory.getStats(); if (!stats.newestEntry || stats.newestEntry < cutoffDate) { return false; } } return true; } }

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