Memory.ts•41.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;
}
}