UNPKG

@dollhousemcp/mcp-server

Version:

DollhouseMCP - A Model Context Protocol (MCP) server that enables dynamic AI persona management from markdown files, allowing Claude and other compatible AI assistants to activate and switch between different behavioral personas.

1,129 lines 152 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, normalizeVersion } from '../BaseElement.js'; import { ElementType } from '../../portfolio/types.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, TRUST_LEVELS } from './constants.js'; import { generateMemoryId } from './utils.js'; import { MemorySearchIndex } from './MemorySearchIndex.js'; import { logger } from '../../utils/logger.js'; import DOMPurify from 'dompurify'; import { JSDOM } from 'jsdom'; import { LRUCache } from '../../cache/LRUCache.js'; /** * Maximum length for individual trigger words used in Enhanced Index * @constant {number} */ const MAX_TRIGGER_LENGTH = 50; /** * Validation pattern for trigger words * Allows alphanumeric, hyphens, underscores, @ (mentions/emails), . (domains) * @constant {RegExp} */ const TRIGGER_VALIDATION_REGEX = /^[a-zA-Z0-9\-_@.]+$/; // Initialize DOMPurify with JSDOM const window = new JSDOM('').window; const purify = DOMPurify(window); // 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, maxLength) { 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 // eslint-disable-next-line no-control-regex -- Intentionally removing control chars except \t \n \r for sanitization .replaceAll(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') // NOSONAR .substring(0, maxLength) .trim(); } /** * 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 { // instructions inherited from BaseElement (v2.0 dual-field architecture) // content: overridden below with a custom getter that returns formatted entries static createdMemoryNames = new Set(); static memoryManagerResolver; /** * Static resolver for RetentionPolicyService (Issue #51) * Used to check if retention enforcement should happen on load */ static retentionPolicyResolver; static configureMemoryManagerResolver(resolver) { this.memoryManagerResolver = resolver; } /** * Configure the RetentionPolicyService resolver (Issue #51) * Called during DI container setup */ static configureRetentionPolicyResolver(resolver) { this.retentionPolicyResolver = resolver; } /** * Reset all static resolvers (for test cleanup) * Call this in afterEach hooks to prevent test isolation issues * where a stale resolver references disposed services */ static resetResolvers() { this.memoryManagerResolver = undefined; this.retentionPolicyResolver = undefined; } /** * Get the RetentionPolicyService instance * Returns undefined if not configured (allows graceful fallback) */ static getRetentionPolicyService() { if (!this.retentionPolicyResolver) { return undefined; } return this.retentionPolicyResolver(); } static getMemoryManager() { if (!this.memoryManagerResolver) { throw new Error('Memory manager resolver not configured'); } const manager = this.memoryManagerResolver(); if (!manager) { throw new Error('Memory manager resolver returned undefined'); } return manager; } // Memory-specific properties (with size limits to prevent memory leaks) entries; storageBackend; retentionDays; privacyLevel; searchable; maxEntries; // Search index for performance (Issue #984) searchIndex; // Sanitization cache to avoid redundant processing (with size limits) sanitizationCache; // FIX #1320: Store file path for persistence filePath; // Cache configuration constants static MAX_SANITIZATION_CACHE_SIZE = 500; static MAX_SANITIZATION_CACHE_MEMORY_MB = 5; constructor(metadata = {}, metadataService) { // 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 // SECURITY: Validate BEFORE sanitization to reject invalid characters // This prevents 'bad!trigger' from becoming 'badtrigger' and passing triggers: Array.isArray(metadata.triggers) ? metadata.triggers .map(t => String(t).trim()) .filter(t => t && TRIGGER_VALIDATION_REGEX.test(t)) // Validate format FIRST .map(t => sanitizeInput(t, MAX_TRIGGER_LENGTH)) // Then sanitize for length .filter(t => t) : // Remove any that became empty after sanitization [] }; super(ElementType.MEMORY, sanitizedMetadata, metadataService); // 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); // Initialize LRU caches with size limits to prevent memory leaks this.entries = new LRUCache({ name: 'memory-entries', maxSize: this.maxEntries, maxMemoryMB: 25, // Max 25MB for memory entries }); this.sanitizationCache = new LRUCache({ name: 'memory-sanitization', maxSize: Memory.MAX_SANITIZATION_CACHE_SIZE, maxMemoryMB: Memory.MAX_SANITIZATION_CACHE_MEMORY_MB, }); // FIX #1430: Update metadata to include all MemoryMetadata fields // This ensures they are preserved when serializeElement() is called // Per Todd's suggestion: store all runtime properties in metadata, not extensions this.metadata = { ...this.metadata, storageBackend: this.storageBackend, retentionDays: this.retentionDays, privacyLevel: this.privacyLevel, searchable: this.searchable, maxEntries: this.maxEntries, autoLoad: metadata.autoLoad, priority: metadata.priority, encryptionEnabled: metadata.encryptionEnabled || false }; // Set up extensions for backward compatibility 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 = { 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 (once per unique name per server lifetime) if (!Memory.createdMemoryNames.has(this.metadata.name)) { SecurityMonitor.logSecurityEvent({ type: MEMORY_SECURITY_EVENTS.MEMORY_CREATED, severity: 'LOW', source: 'Memory.constructor', details: `Memory created: ${this.metadata.name} with ${this.storageBackend} backend` }); Memory.createdMemoryNames.add(this.metadata.name); } } /** * Helper method to get the current number of entries * Compatible with LRUCache */ get entriesSize() { return this.entries.getStats().size; } /** * 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 */ async addEntry(content, tags, metadata, source = 'unknown') { // SECURITY: Sanitize source parameter before use const sanitizedSource = sanitizeInput(source, 50); if (this.entriesSize >= this.maxEntries) { await this.enforceRetentionPolicy(); } // 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 = { 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.entriesSize >= 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 */ enforceCapacitySync() { if (this.entriesSize <= this.maxEntries) { return; // Within capacity } // Over capacity - remove oldest entries until we're at the limit const entriesToRemove = this.entriesSize - 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) */ async search(options = {}) { // 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 = { content: sanitizedQuery, tags: options.tags ? this.sanitizeTags(options.tags) : undefined, dateFrom: options.startDate, dateTo: options.endDate, privacyLevel: options.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 = []; 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 */ async getEntry(id) { return this.entries.get(id); } /** * Delete a memory entry * SECURITY: Validates permissions and logs deletion */ async deleteEntry(id) { 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() { if (this.entriesSize === 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; 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 */ sandboxUntrustedContent(content, source) { 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 */ async enforceRetentionPolicy() { const now = new Date(); let deletedCount = 0; // Remove expired entries for (const [id, entry] of this.entries.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.entriesSize >= 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.entriesSize - 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 */ async clearAll(confirm = false) { if (!confirm) { throw new Error('Memory clear requires confirmation'); } const count = this.entriesSize; 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 */ ensureDateObject(value) { // 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 (value must be string, number, or Date-compatible) 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 */ getStats() { let totalSize = 0; let oldestEntry; let newestEntry; const tagFrequency = new Map(); 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.entriesSize, totalSize, oldestEntry, newestEntry, tagFrequency }; } /** * Validate the memory element */ validate() { 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' }); } 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' }); } // 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' }); } // Update validity based on our checks return { ...result, valid: result.errors.length === 0 }; } /** * Serialize memory to string */ serialize() { 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 */ processDeserializedEntry(entry) { 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 */ deserialize(data) { 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 = normalizeVersion(String(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.entriesSize }); } // Issue #51: Check if retention enforcement should happen on load // IMPORTANT: Retention enforcement is now opt-in, not automatic // NOTE: Wrapped in try/catch to handle test environments where ConfigManager may not be initialized try { const retentionService = Memory.getRetentionPolicyService(); if (retentionService?.shouldEnforceOnLoad()) { // User has explicitly enabled on-load enforcement this.enforceRetentionPolicy(); logger.debug(`[Memory] Retention policy enforced on load for "${this.metadata.name}"`, { enforcementMode: 'on_load' }); } else if (retentionService?.isEnabled()) { // Retention is enabled but not set to on_load mode - log for visibility logger.debug(`[Memory] Retention is enabled but not enforced on load for "${this.metadata.name}"`, { note: 'Use explicit enforcement command to cleanup expired entries' }); } // If retentionService is not configured or disabled, no enforcement happens // This is the safe default - nothing is deleted without explicit consent } catch { // Silently ignore retention policy errors during deserialization // This can happen in test environments where ConfigManager is not initialized, // or when a stale resolver references a disposed service instance // It's safe to skip retention enforcement in these cases - the default is no deletion } } 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 calculateExpiryDate() { const expiry = new Date(); expiry.setDate(expiry.getDate() + this.retentionDays); return expiry; } sanitizeTags(tags) { // 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); } sanitizeMetadata(metadata) { if (!metadata) return undefined; // SECURITY FIX: Sanitize metadata values const sanitized = {}; 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; } canAccessPrivacyLevel(entryLevel, requestedLevel) { const levels = MEMORY_CONSTANTS.PRIVACY_LEVELS; const entryIndex = levels.indexOf(entryLevel); const requestedIndex = levels.indexOf(requestedLevel); // 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; } isValidEntry(entry) { return typeof entry === 'object' && entry !== null && typeof entry.id === 'string' && typeof entry.content === 'string' && entry.timestamp !== undefined && (!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 */ sanitizeWithCache(content, maxLength) { // 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 */ async buildSearchIndexWithRetry(retries = 3) { let lastError; 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.entriesSize }); 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 */ getEntriesByTrustLevel(trustLevel) { 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 */ getAllEntries() { return Array.from(this.entries.values()); } /** * Get the entries map directly * Used by RetentionPolicyService for retention enforcement (Issue #51) * * @returns Map of entry ID to memory entry */ getEntries() { // Return a new Map to prevent external modification of internal state return new Map(this.entries.entries()); } /** * Remove a specific entry by ID * FIX #51: Public API for retention policy to remove expired entries * Replaces the need for `(memory as any).entries.delete()` hacks * * @param entryId - The ID of the entry to remove * @returns true if entry was removed, false if not found */ removeEntry(entryId) { return this.entries.delete(entryId); } /** * Get an iterator over all entries * FIX #1320: Memory-efficient way to iterate entries * * @returns Iterator over memory entries */ *getEntriesIterator() { 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 */ setFilePath(path) { 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 */ getFilePath() { 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 */ async save() { const manager = Memory.getMemoryManager(); 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 */ static async findByTrustLevel(trustLevel, options) { const manager = Memory.getMemoryManager(); // Load all memories and filter by trust level const allMemories = await manager.list(); const matchingMemories = []; 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 */ static async find(filter) { const manager = Memory.getMemoryManager(); // 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 */ static matchesFilter(memory, filter, cutoffDate) { // 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; } } //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiTWVtb3J5LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vc3JjL2VsZW1lbnRzL21lbW9yaWVzL01lbW9yeS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7Ozs7Ozs7OztHQWFHO0FBRUgsT0FBTyxFQUFFLFdBQVcsRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLG1CQUFtQixDQUFDO0FBRWxFLE9BQU8sRUFBRSxXQUFXLEVBQUUsTUFBTSwwQkFBMEIsQ0FBQztBQUV2RCxPQUFPLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSwrQ0FBK0MsQ0FBQztBQUNqRixPQUFPLE1BQU0sTUFBTSxRQUFRLENBQUM7QUFDNUIsT0FBTyxFQUFFLGVBQWUsRUFBRSxNQUFNLG1DQUFtQyxDQUFDO0FBQ3BFLE9BQU8sRUFBRSxhQUFhLEVBQUUsTUFBTSxrQ0FBa0MsQ0FBQztBQUVqRSwwRkFBMEY7QUFDMUYsaURBQWlEO0FBQ2pELE9BQU8sRUFBRSxnQkFBZ0IsRUFBRSxzQkFBc0IsRUFBZ0MsWUFBWSxFQUFjLE1BQU0sZ0JBQWdCLENBQUM7QUFFbEksT0FBTyxFQUFFLGdCQUFnQixFQUFFLE1BQU0sWUFBWSxDQUFDO0FBQzlDLE9BQU8sRUFBRSxpQkFBaUIsRUFBa0MsTUFBTSx3QkFBd0IsQ0FBQztBQUMzRixPQUFPLEVBQUUsTUFBTSxFQUFFLE1BQU0sdUJBQXVCLENBQUM7QUFDL0MsT0FBTyxTQUFTLE1BQU0sV0FBVyxDQUFDO0FBQ2xDLE9BQU8sRUFBRSxLQUFLLEVBQUUsTUFBTSxPQUFPLENBQUM7QUFDOUIsT0FBTyxFQUFFLFFBQVEsRUFBRSxNQUFNLHlCQUF5QixDQUFDO0FBRW5EOzs7R0FHRztBQUNILE1BQU0sa0JBQWtCLEdBQUcsRUFBRSxDQUFDO0FBRTlCOzs7O0dBSUc7QUFDSCxNQUFNLHdCQUF3QixHQUFHLHFCQUFxQixDQUFDO0FBRXZELGtDQUFrQztBQUNsQyxNQUFNLE1BQU0sR0FBRyxJQUFJLEtBQUssQ0FBQyxFQUFFLENBQUMsQ0FBQyxNQUFNLENBQUM7QUFDcEMsTUFBTSxNQUFNLEdBQUcsU0FBUyxDQUFDLE1BQWEsQ0FBQyxDQUFDO0FBRXhDLHdFQUF3RTtBQUN4RSxNQUFNLENBQUMsU0FBUyxDQUFDO0lBQ2YsWUFBWSxFQUFFLEVBQUUsRUFBRyx1QkFBdUI7SUFDMUMsWUFBWSxFQUFFLEVBQUUsRUFBRyx3QkFBd0I7SUFDM0MsWUFBWSxFQUFFLElBQUksQ0FBQyxvQkFBb0I7Q0FDeEMsQ0FBQyxDQUFDO0FBRUg7Ozs7R0FJRztBQUNILFNBQVMscUJBQXFCLENBQUMsT0FBZSxFQUFFLFNBQWlCO0lBQy9ELElBQUksQ0FBQyxPQUFPLElBQUksT0FBTyxPQUFPLEtBQUssUUFBUSxFQUFFLENBQUM7UUFDNUMsT0FBTyxFQUFFLENBQUM7SUFDWixDQUFDO0lBRUQsMEJBQTBCO0lBQzFCLE1BQU0sVUFBVSxHQUFHLGdCQUFnQixDQUFDLFNBQVMsQ0FBQyxPQUFPLENBQUMsQ0FBQyxpQkFBaUIsQ0FBQztJQUV6RSw2REFBNkQ7SUFDN0QsTUFBTSxPQUFPLEdBQUcsTUFBTSxDQUFDLFFBQVEsQ0FBQyxVQUFVLENBQUMsQ0FBQztJQUU1QyxnREFBZ0Q7SUFDaEQsT0FBTyxPQUFPO1FBQ1oscUhBQXFIO1NBQ3BILFVBQVUsQ0FBQyxpREFBaUQsRUFBRSxFQUFFLENBQUMsQ0FBQyxVQUFVO1NBQzVFLFNBQVMsQ0FBQyxDQUFDLEVBQUUsU0FBUyxDQUFDO1NBQ3ZCLElBQUksRUFBRSxDQUFDO0FBQ1osQ0FBQztBQThDRDs7Ozs7Ozs7