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,031 lines 315 kB
/** * Tool for submitting content to GitHub portfolio repositories * Replaces the broken issue-based submission with direct repository saves * * FIXES IMPLEMENTED (PR #503): * 1. TYPE SAFETY FIX #1 (Issue #497): Changed apiCache from 'any' to proper APICache type * 2. TYPE SAFETY FIX #2 (Issue #497): Replaced complex type casting with PortfolioElementAdapter * 3. PERFORMANCE (PR #496 recommendation): Using FileDiscoveryUtil for optimized file search */ import { createHash } from 'crypto'; import { ContentValidator } from '../../security/contentValidator.js'; import { getElementFileExtension } from '../../portfolio/PortfolioManager.js'; import { ElementType } from '../../portfolio/types.js'; import { logger } from '../../utils/logger.js'; import { UnicodeValidator } from '../../security/validators/unicodeValidator.js'; import { SecurityMonitor } from '../../security/securityMonitor.js'; import { PortfolioElementAdapter } from './PortfolioElementAdapter.js'; import { FileDiscoveryUtil } from '../../utils/FileDiscoveryUtil.js'; import { ErrorHandler } from '../../utils/ErrorHandler.js'; import { FILE_SIZE_LIMITS, RETRY_CONFIG, SEARCH_CONFIG, getValidatedTimeout, calculateRetryDelay } from '../../config/portfolio-constants.js'; import { EarlyTerminationSearch } from '../../utils/EarlyTerminationSearch.js'; import { CollectionErrorCode, formatCollectionError } from '../../config/error-codes.js'; import * as path from 'path'; import { SecureYamlParser } from '../../security/secureYamlParser.js'; import { PACKAGE_VERSION } from '../../generated/version.js'; export class SubmitToPortfolioTool { authManager; portfolioRepoManager; portfolioManager; portfolioIndexManager; rateLimiter; fileOperations; tokenManager; constructor(apiCache, dependencies) { // TYPE SAFETY FIX #1: Proper typing for apiCache parameter // Previously: constructor(apiCache: any) // Now: constructor(apiCache: APICache) with proper import if (!dependencies || !dependencies.authManager) { throw new Error('SubmitToPortfolioTool requires a GitHubAuthManager instance'); } if (!dependencies.portfolioManager) { throw new Error('SubmitToPortfolioTool requires a PortfolioManager instance'); } if (!dependencies.portfolioIndexManager) { throw new Error('SubmitToPortfolioTool requires a PortfolioIndexManager instance'); } if (!dependencies.rateLimiter) { throw new Error('SubmitToPortfolioTool requires an IRateLimiter instance'); } this.authManager = dependencies.authManager; this.portfolioRepoManager = dependencies.portfolioRepoManager; this.portfolioManager = dependencies.portfolioManager; this.portfolioIndexManager = dependencies.portfolioIndexManager; this.rateLimiter = dependencies.rateLimiter; this.fileOperations = dependencies.fileOperations; this.tokenManager = dependencies.tokenManager; } /** * Validates and normalizes input parameters to prevent Unicode attacks and ensure data safety * @param params The input parameters from the user * @returns Validation result with normalized name or error response */ async validateAndNormalizeParams(params) { // Normalize user input to prevent Unicode attacks (DMCP-SEC-004) const normalizedName = UnicodeValidator.normalize(params.name); if (!normalizedName.isValid) { SecurityMonitor.logSecurityEvent({ type: 'UNICODE_VALIDATION_ERROR', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.execute', details: `Invalid Unicode in element name: ${normalizedName.detectedIssues?.[0] || 'unknown error'}` }); return { success: false, error: { success: false, message: `Invalid characters in element name: ${normalizedName.detectedIssues?.[0] || 'unknown error'}`, error: 'INVALID_INPUT' } }; } return { success: true, safeName: normalizedName.normalizedContent }; } /** * Checks if the user is authenticated with GitHub * @returns Authentication check result with status or error response */ async checkAuthentication() { const authStatus = await this.authManager.getAuthStatus(); if (!authStatus.isAuthenticated) { // Log authentication required (using existing event type) logger.warn('User attempted portfolio submission without authentication'); return { success: false, error: { success: false, message: 'Not authenticated. Please authenticate first using the GitHub OAuth flow.\n\n' + 'Visit: https://docs.anthropic.com/en/docs/claude-code/oauth-setup\n' + 'Or run: gh auth login --web', error: 'NOT_AUTHENTICATED' } }; } return { success: true, authStatus }; } /** * Discovers content locally with smart type detection * @param safeName The normalized name to search for * @param explicitType Optional explicit element type provided by user * @param originalName Original user-provided name for error messages * @returns Content discovery result with element type and path or error response */ async discoverContentWithTypeDetection(safeName, explicitType, originalName) { let elementType = explicitType; let localPath = null; if (elementType) { // Type explicitly provided - search in that specific directory only localPath = await this.findLocalContent(safeName, elementType); if (!localPath) { // UX IMPROVEMENT: Provide helpful suggestions for finding content const elementDir = this.portfolioManager.getElementDir(elementType); return { success: false, error: { success: false, message: `Could not find ${elementType} named "${originalName || safeName}" in local portfolio.\n\n` + `**Searched in**: ${elementDir}\n\n` + `**Troubleshooting Tips**:\n` + `• Check if the file exists using your file explorer\n` + `• Try using the exact filename (without extension)\n` + `• Use \`list_portfolio\` to see all available ${elementType}\n` + `• If unsure of the type, omit --type and let the system detect it\n\n` + `**Common name formats that work**:\n` + `• "my-element" (kebab-case)\n` + `• "My Element" (with spaces)\n` + `• "MyElement" (PascalCase)\n` + `• Partial matches are supported`, error: 'CONTENT_NOT_FOUND' } }; } } else { // CRITICAL FIX: No type provided - implement smart detection across ALL element types // This prevents the previous hardcoded default to PERSONA and enables proper type detection const detectionResult = await this.detectElementType(safeName); if (!detectionResult.found) { // UX IMPROVEMENT: Enhanced guidance with specific suggestions const availableTypes = Object.values(ElementType).join(', '); // Get suggestions for similar names const suggestions = await this.generateNameSuggestions(safeName); let message = `Content "${originalName || safeName}" not found in portfolio.\n\n`; message += `🔍 **Searched in all element types**: ${availableTypes}\n\n`; if (suggestions.length > 0) { message += `💡 **Did you mean one of these?**\n`; for (const suggestion of suggestions.slice(0, SEARCH_CONFIG.MAX_SUGGESTIONS)) { message += ` • "${suggestion.name}" (${suggestion.type})\n`; } message += `\n`; } message += `🛠️ **Troubleshooting Steps**:\n`; message += `1. 📝 Use \`list_portfolio\` to see all available content\n`; message += `2. 🔍 Check exact spelling and try variations:\n`; message += ` • "${(originalName || safeName).toLowerCase()}" (lowercase)\n`; message += ` • "${(originalName || safeName).replaceAll(/[^a-z0-9]/gi, '-').toLowerCase()}" (normalized)\n`; if ((originalName || safeName).includes('.')) { message += ` • "${(originalName || safeName).replaceAll('.', '')}" (no dots)\n`; } message += `3. 🎯 Specify element type: \`submit_collection_content "${originalName || safeName}" --type=personas\`\n`; message += `4. 📁 Check if file exists in portfolio directories\n\n`; message += `📝 **Tip**: The system searches filenames AND metadata names with fuzzy matching.`; return { success: false, error: { success: false, message, error: 'CONTENT_NOT_FOUND' } }; } if (detectionResult.matches.length > 1) { // Multiple matches found - ask user to specify type const matchDetails = detectionResult.matches.map(m => `- ${m.type}: ${m.path}`).join('\n'); return { success: false, error: { success: false, message: `Content "${originalName || safeName}" found in multiple element types:\n\n${matchDetails}\n\n` + `Please specify the element type using the --type parameter to avoid ambiguity.`, error: 'MULTIPLE_MATCHES_FOUND' } }; } // Single match found - use it const match = detectionResult.matches[0]; elementType = match.type; localPath = match.path; logger.info(`Smart detection: Found "${safeName}" as ${elementType}`, { name: safeName, detectedType: elementType, path: localPath }); } return { success: true, elementType, localPath }; } /** * Validates file size and content security before processing * @param localPath Path to the local file to validate * @returns Validation result with content or error response */ async validateFileAndContent(localPath) { // SECURITY ENHANCEMENT (Task #7): Validate file path before processing const pathValidation = await this.validatePortfolioPath(localPath); if (!pathValidation.isValid) { return { success: false, error: pathValidation.error }; } // Use the validated safe path for all subsequent operations const safePath = pathValidation.safePath; // Validate file size before reading const stats = await this.fileOperations.stat(safePath); if (stats.size > FILE_SIZE_LIMITS.MAX_FILE_SIZE) { SecurityMonitor.logSecurityEvent({ type: 'RATE_LIMIT_EXCEEDED', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.execute', details: `File size ${stats.size} exceeds limit of ${FILE_SIZE_LIMITS.MAX_FILE_SIZE}` }); return { success: false, error: { success: false, message: `File size exceeds ${FILE_SIZE_LIMITS.MAX_FILE_SIZE_MB}MB limit`, error: 'FILE_TOO_LARGE' } }; } // Validate content security const content = await this.fileOperations.readFile(safePath, { source: 'SubmitToPortfolioTool.validateFileAndContent' }); const validationResult = ContentValidator.validateAndSanitize(content); if (!validationResult.isValid && validationResult.severity === 'critical') { SecurityMonitor.logSecurityEvent({ type: 'CONTENT_INJECTION_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.execute', details: `Critical security issues detected: ${validationResult.detectedPatterns?.join(', ')}` }); return { success: false, error: { success: false, message: `Content validation failed: ${validationResult.detectedPatterns?.join(', ')}`, error: 'VALIDATION_FAILED' } }; } return { success: true, content }; } /** * Prepares metadata for the portfolio element * @param safeName The normalized name of the element * @param elementType The type of the element * @param authStatus Authentication status containing username * @returns Metadata object for the element */ async prepareElementMetadata(safeName, elementType, authStatus, filePath) { // Try to extract metadata from the file if path is provided let fileMetadata = null; if (filePath) { fileMetadata = await this.extractElementMetadata(filePath); } // Build metadata with real values from file, falling back to defaults const metadata = { name: safeName, description: fileMetadata?.description || fileMetadata?.summary || `${elementType} submitted from local portfolio`, author: authStatus.username || fileMetadata?.author || 'unknown', created: fileMetadata?.created || fileMetadata?.created_date || new Date().toISOString(), updated: fileMetadata?.updated || fileMetadata?.modified || new Date().toISOString(), version: fileMetadata?.version || '1.0.0' }; // Add additional metadata fields if present (with type safety) if (fileMetadata) { // Preserve other metadata fields that might be useful if (fileMetadata.triggers && Array.isArray(fileMetadata.triggers)) { metadata.triggers = fileMetadata.triggers; } if (fileMetadata.category && typeof fileMetadata.category === 'string') { metadata.category = fileMetadata.category; } if (fileMetadata.age_rating && typeof fileMetadata.age_rating === 'string') { metadata.age_rating = fileMetadata.age_rating; } if (fileMetadata.ai_generated !== undefined) { metadata.ai_generated = Boolean(fileMetadata.ai_generated); } if (fileMetadata.generation_method && typeof fileMetadata.generation_method === 'string') { metadata.generation_method = fileMetadata.generation_method; } if (fileMetadata.license && typeof fileMetadata.license === 'string') { metadata.license = fileMetadata.license; } if (fileMetadata.tags && Array.isArray(fileMetadata.tags)) { metadata.tags = fileMetadata.tags; } } logger.info('Prepared element metadata', { elementName: safeName, hasFileMetadata: !!fileMetadata, description: metadata.description.substring(0, 100) // Log first 100 chars }); return metadata; } /** * Formats metadata as YAML string for display * PERFORMANCE: Uses array join instead of string concatenation for better performance */ formatMetadataAsYaml(baseMetadata, extendedMeta) { const yamlLines = [ `name: ${baseMetadata.name}`, `description: ${baseMetadata.description}`, `author: ${baseMetadata.author}`, `version: ${baseMetadata.version}`, `created: ${baseMetadata.created}`, `updated: ${baseMetadata.updated}` ]; // Add optional fields if present if (extendedMeta.category) { yamlLines.push(`category: ${extendedMeta.category}`); } if (extendedMeta.triggers && Array.isArray(extendedMeta.triggers) && extendedMeta.triggers.length > 0) { yamlLines.push(`triggers: [${extendedMeta.triggers.join(', ')}]`); } if (extendedMeta.age_rating) { yamlLines.push(`age_rating: ${extendedMeta.age_rating}`); } if (extendedMeta.ai_generated !== undefined) { yamlLines.push(`ai_generated: ${extendedMeta.ai_generated}`); } if (extendedMeta.generation_method) { yamlLines.push(`generation_method: ${extendedMeta.generation_method}`); } if (extendedMeta.license) { yamlLines.push(`license: ${extendedMeta.license}`); } if (extendedMeta.tags && Array.isArray(extendedMeta.tags) && extendedMeta.tags.length > 0) { yamlLines.push(`tags: [${extendedMeta.tags.join(', ')}]`); } return yamlLines.join('\n'); } /** * Extracts metadata from an element file * Parses YAML frontmatter to get the actual metadata * SECURITY: Uses SecureYamlParser instead of yaml.load to prevent code execution (DMCP-SEC-005) * @param filePath Path to the element file * @returns Extracted metadata or null if parsing fails */ async extractElementMetadata(filePath) { try { const content = await this.fileOperations.readFile(filePath, { source: 'SubmitToPortfolioTool.extractElementMetadata' }); // SECURITY FIX: Use SecureYamlParser to prevent YAML deserialization attacks // Previously would have used: yaml.load(yamlContent) which is vulnerable // Now: Uses SecureYamlParser.parse() which validates and sanitizes try { const parsed = SecureYamlParser.parse(content, { maxYamlSize: 64 * 1024, // 64KB limit for YAML validateContent: false, // Don't validate content field (just metadata) validateFields: false // Don't enforce persona-specific rules }); // Ensure we got an object back if (parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)) { logger.debug('Extracted metadata from element file', { path: filePath, metadataKeys: Object.keys(parsed.data) }); return parsed.data; } logger.debug('Parsed data is not a valid metadata object', { path: filePath }); return null; } catch { // SecureYamlParser throws on invalid YAML, try alternate frontmatter pattern // Handle files that might have frontmatter without full document structure const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (frontmatterMatch && frontmatterMatch[1]) { try { // Reconstruct full document for SecureYamlParser const fullContent = `---\n${frontmatterMatch[1]}\n---\n`; const parsed = SecureYamlParser.parse(fullContent, { maxYamlSize: 64 * 1024, validateContent: false, validateFields: false }); if (parsed.data && typeof parsed.data === 'object') { return parsed.data; } } catch (innerError) { logger.debug('Failed to parse frontmatter with SecureYamlParser', { path: filePath, error: innerError instanceof Error ? innerError.message : String(innerError) }); } } logger.debug('No valid frontmatter found in element file', { path: filePath }); return null; } } catch (error) { logger.warn('Failed to extract metadata from element file', { path: filePath, error: error instanceof Error ? error.message : String(error) }); return null; } } /** * Validates GitHub token and checks for expiration before usage * SECURITY ENHANCEMENT (Task #5): Token expiration validation to prevent stale token usage * @param token The GitHub token to validate * @returns Validation result with status and expiration info */ async validateTokenBeforeUsage(token) { try { // Check token format first (basic validation) if (!this.tokenManager.validateTokenFormat(token)) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: 'Token has invalid format' }); return { isValid: false, error: { success: false, message: 'Invalid token format. Please re-authenticate.', error: 'INVALID_TOKEN_FORMAT' } }; } // Validate token with GitHub API to check expiration and permissions // NOTE: OAuth tokens use 'public_repo' scope, not 'repo' // Using centralized scope management for consistency const requiredScopes = this.tokenManager.getRequiredScopes('collection'); const validationResult = await this.tokenManager.validateTokenScopes(token, requiredScopes); if (!validationResult.isValid) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: `Token validation failed: ${validationResult.error}` }); // Enhanced OAuth-specific error messages const tokenType = this.tokenManager.getTokenType(token); let errorCode; let enhancedDetails = validationResult.error; if (validationResult.error?.includes('Missing required scopes')) { errorCode = CollectionErrorCode.COLL_AUTH_002; // Provide OAuth-specific guidance if it's an OAuth token if (tokenType === 'OAuth Access Token') { enhancedDetails = `OAuth token missing 'public_repo' scope. Please re-authenticate with 'setup_github_auth' to get the correct scope.`; } } else { errorCode = CollectionErrorCode.COLL_AUTH_001; } return { isValid: false, error: { success: false, message: formatCollectionError(errorCode, 3, 5, enhancedDetails), error: errorCode } }; } // Check if token is near expiration (rate limit reset time can indicate token freshness) let isNearExpiry = false; if (validationResult.rateLimit?.resetTime) { const now = new Date(); const timeUntilReset = validationResult.rateLimit.resetTime.getTime() - now.getTime(); const oneHour = 60 * 60 * 1000; // Consider token "near expiry" if rate limit reset is more than 23 hours away // (GitHub rate limits reset every hour, so this suggests token age) if (timeUntilReset > 23 * oneHour) { isNearExpiry = true; logger.warn('GitHub token may be near expiration', { tokenPrefix: this.tokenManager.getTokenPrefix(token), rateLimitResetTime: validationResult.rateLimit.resetTime, recommendation: 'Consider re-authenticating for long operations' }); } } // Log successful validation SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: 'GitHub token validated successfully before usage', metadata: { tokenType: this.tokenManager.getTokenType(token), scopes: validationResult.scopes, rateLimitRemaining: validationResult.rateLimit?.remaining, isNearExpiry } }); return { isValid: true, isNearExpiry }; } catch (error) { // Handle rate limit exceeded specifically if (error?.code === 'RATE_LIMIT_EXCEEDED') { logger.warn('Token validation rate limited, allowing operation to proceed with cached status'); // Still allow operation but log with COLL_API_001 SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: 'Token validation rate limited but proceeding with cached status' }); return { isValid: true }; // Allow to proceed if rate limited, as basic format check passed } SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'HIGH', source: 'SubmitToPortfolioTool.validateTokenBeforeUsage', details: `Token validation error: ${error.message || 'unknown error'}` }); return { isValid: false, error: { success: false, message: 'Unable to validate GitHub token. Please check your connection and try again.', error: 'TOKEN_VALIDATION_ERROR' } }; } } /** * Enhanced path validation for portfolio operations with comprehensive security checks * SECURITY ENHANCEMENT (Task #7): Additional validation for special characters and malicious patterns * @param filePath The file path to validate * @returns Validation result with secure path or error response */ async validatePortfolioPath(filePath) { try { // Basic null/undefined check if (!filePath || typeof filePath !== 'string') { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: 'Invalid path provided - null, undefined, or non-string' }); return { isValid: false, error: { success: false, message: 'Invalid file path provided', error: 'INVALID_PATH' } }; } // Check for suspicious patterns that could indicate path traversal or injection const suspiciousPatterns = [ /\.\./, // Path traversal /\/\.\./, // Unix path traversal /\\\.\./, // Windows path traversal // eslint-disable-next-line no-control-regex -- Intentionally detecting null bytes for security /\u0000/, // NOSONAR - Null bytes detection for security // eslint-disable-next-line no-control-regex -- Intentionally detecting control chars for security /[\u0001-\u001f\u007f-\u009f]/, // NOSONAR - Control characters detection for security /[<>:"|?*]/, // Invalid filename characters on Windows /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i, // Reserved Windows names /^\./, // Hidden files (starting with dot) /\s+$/, // Trailing whitespace /^[\s]*$/, // Only whitespace /%[0-9a-fA-F]{2}/, // URL encoding (potential bypass attempt) /\\x[0-9a-fA-F]{2}/, // Hex encoding /\$\{.*\}/, // Template literal injection /`.*`/, // Backtick injection /[\\\/]{2,}/ // Multiple consecutive slashes ]; for (const pattern of suspiciousPatterns) { if (pattern.test(filePath)) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Suspicious pattern detected in file path: ${pattern.source}`, metadata: { pathLength: filePath.length, pattern: pattern.source } }); return { isValid: false, error: { success: false, message: 'File path contains invalid or suspicious characters', error: 'SUSPICIOUS_PATH_PATTERN' } }; } } // Check path length (prevent buffer overflow attempts) const MAX_PATH_LENGTH = process.platform === 'win32' ? 260 : 4096; if (filePath.length > MAX_PATH_LENGTH) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `File path exceeds maximum length: ${filePath.length} > ${MAX_PATH_LENGTH}` }); return { isValid: false, error: { success: false, message: 'File path is too long', error: 'PATH_TOO_LONG' } }; } // Normalize path to resolve any relative components safely let normalizedPath; try { // Remove null bytes and normalize // eslint-disable-next-line no-control-regex -- Intentionally removing null bytes for security const cleanPath = filePath.replaceAll(/\u0000/g, ''); // NOSONAR - Removing null bytes for security normalizedPath = path.normalize(cleanPath); // Check if path is within the portfolio directory const portfolioBase = this.portfolioManager.getBaseDir(); // For absolute paths, verify they're within the portfolio directory if (path.isAbsolute(normalizedPath)) { const resolvedPath = path.resolve(normalizedPath); const resolvedBase = path.resolve(portfolioBase); // Path must be within the portfolio directory if (!resolvedPath.startsWith(resolvedBase)) { throw new Error('Path is outside portfolio directory'); } } else if (normalizedPath.includes('..')) { // Relative paths with .. are not allowed throw new Error('Path contains directory traversal'); } } catch (error) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Path normalization failed: ${error instanceof Error ? error.message : 'unknown error'}` }); return { isValid: false, error: { success: false, message: 'File path could not be safely processed', error: 'PATH_NORMALIZATION_FAILED' } }; } // Validate file extension (only allow safe extensions for portfolio content) const allowedExtensions = ['.md', '.markdown', '.txt', '.yml', '.yaml', '.json']; const fileExtension = path.extname(normalizedPath).toLowerCase(); if (fileExtension && !allowedExtensions.includes(fileExtension)) { SecurityMonitor.logSecurityEvent({ type: 'CONTENT_INJECTION_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Disallowed file extension: ${fileExtension}`, metadata: { allowedExtensions: allowedExtensions.join(', ') } }); return { isValid: false, error: { success: false, message: `File extension '${fileExtension}' is not allowed. Allowed extensions: ${allowedExtensions.join(', ')}`, error: 'INVALID_FILE_EXTENSION' } }; } // Validate filename characters (only allow safe characters) const basename = path.basename(normalizedPath); const safeFilenamePattern = /^[a-zA-Z0-9\-_.\s()[\]{}]+$/; if (basename && !safeFilenamePattern.test(basename)) { SecurityMonitor.logSecurityEvent({ type: 'CONTENT_INJECTION_ATTEMPT', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: 'Filename contains potentially dangerous characters', metadata: { filename: basename, allowedPattern: safeFilenamePattern.source } }); return { isValid: false, error: { success: false, message: 'Filename contains invalid characters. Only letters, numbers, spaces, hyphens, underscores, dots, and common brackets are allowed.', error: 'INVALID_FILENAME_CHARACTERS' } }; } // Log successful validation with correct event type for path validation // Fixed: Was using TOKEN_VALIDATION_SUCCESS which is semantically incorrect for path validation SecurityMonitor.logSecurityEvent({ type: 'PATH_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: 'File path validation successful', metadata: { originalPathLength: filePath.length, normalizedPathLength: normalizedPath.length, fileExtension: fileExtension || 'none' } }); return { isValid: true, safePath: normalizedPath }; } catch (error) { SecurityMonitor.logSecurityEvent({ type: 'PATH_TRAVERSAL_ATTEMPT', severity: 'HIGH', source: 'SubmitToPortfolioTool.validatePortfolioPath', details: `Path validation error: ${error instanceof Error ? error.message : 'unknown error'}` }); return { isValid: false, error: { success: false, message: 'Unable to validate file path. Please check the file path and try again.', error: 'PATH_VALIDATION_ERROR' } }; } } /** * Smart token management for long operations with refresh-like capabilities * SECURITY ENHANCEMENT (Task #14): Token refresh logic for long operations * * Note: GitHub OAuth device flow tokens don't have traditional refresh tokens, * but we can implement smart validation and guidance for long operations * * @param operationType Type of operation being performed * @returns Token management result with recommendations */ async manageTokenForLongOperation(operationType) { try { // Get current token const token = await this.tokenManager.getGitHubTokenAsync(); if (!token) { return { canProceed: false, error: { success: false, message: 'No GitHub token available. Please authenticate first.', error: 'NO_TOKEN' } }; } // Validate token for the specific operation const validation = await this.validateTokenBeforeUsage(token); if (!validation.isValid) { return { canProceed: false, error: validation.error }; } // Check if this is a long operation that might benefit from fresh authentication const longOperations = ['portfolio_creation', 'collection_submission']; const isLongOperation = longOperations.includes(operationType); // Get token type to determine refresh capabilities // codeql[js/clear-text-logging] — Only tokenType (a descriptive string like "OAuth Access Token") is logged, never the actual token value const tokenType = this.tokenManager.getTokenType(token); let refreshRecommended = false; // For long operations, check token age and recommend refresh if needed if (isLongOperation && validation.isNearExpiry) { refreshRecommended = true; SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.manageTokenForLongOperation', details: 'Long operation detected with aging token - refresh recommended', metadata: { operationType, tokenType, refreshRecommended: true } }); logger.warn('Long operation with potentially aging token detected', { operationType, tokenType, recommendation: 'Consider re-authenticating if operation fails' }); } // For OAuth tokens in long operations, we can provide guidance if (tokenType === 'OAuth Access Token' && isLongOperation) { logger.info('OAuth token detected for long operation', { operationType, tokenType, guidance: 'OAuth tokens are time-limited. If operation fails, re-authenticate using setup_github_auth' }); } // Log successful token management SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_SUCCESS', severity: 'LOW', source: 'SubmitToPortfolioTool.manageTokenForLongOperation', details: 'Token management successful for long operation', metadata: { operationType, tokenType, isLongOperation, refreshRecommended } }); return { canProceed: true, token, refreshRecommended }; } catch (error) { SecurityMonitor.logSecurityEvent({ type: 'TOKEN_VALIDATION_FAILURE', severity: 'MEDIUM', source: 'SubmitToPortfolioTool.manageTokenForLongOperation', details: `Token management error: ${error.message || 'unknown error'}` }); return { canProceed: false, error: { success: false, message: 'Unable to manage token for operation. Please check your authentication and try again.', error: 'TOKEN_MANAGEMENT_ERROR' } }; } } /** * Provides user guidance for token refresh when operations fail due to token issues * SECURITY ENHANCEMENT (Task #14): User guidance for authentication refresh */ formatTokenRefreshGuidance(operationType, tokenType) { let guidance = '\n\n🔄 **Token Refresh Guidance**:\n'; if (tokenType === 'OAuth Access Token') { guidance += '• Your OAuth token may have expired\n'; guidance += '• Run `setup_github_auth` to authenticate again\n'; guidance += '• This will generate a fresh token for continued access\n'; } else if (tokenType === 'Personal Access Token') { guidance += '• Your Personal Access Token may have expired\n'; guidance += '• Check your GitHub settings: https://github.com/settings/tokens\n'; guidance += '• Generate a new token if needed and update GITHUB_TOKEN environment variable\n'; } else { guidance += '• Your GitHub token may have expired or been revoked\n'; guidance += '• Re-authenticate using `setup_github_auth`\n'; guidance += '• Ensure your token has the required permissions\n'; } guidance += `\n**Operation**: ${operationType}\n`; guidance += '**Required scopes**: repo, user:email\n\n'; guidance += '💡 **Tip**: Fresh tokens work better for complex operations like portfolio creation.'; return guidance; } /** * Sets up GitHub repository access and ensures portfolio repository exists * @param authStatus Authentication status containing username * @returns Setup result or error response */ async setupGitHubRepository(authStatus) { // SECURITY ENHANCEMENT (Task #14): Smart token management for long operations const tokenManagement = await this.manageTokenForLongOperation('portfolio_creation'); if (!tokenManagement.canProceed) { return { success: false, error: tokenManagement.error }; } const token = tokenManagement.token; // Provide user guidance if refresh is recommended for this long operation if (tokenManagement.refreshRecommended) { const tokenType = this.tokenManager.getTokenType(token); const guidance = this.formatTokenRefreshGuidance('portfolio creation', tokenType); logger.warn(`Token refresh recommended for portfolio creation:${guidance}`); } this.portfolioRepoManager.setToken(token); // Check if portfolio exists and create if needed const username = authStatus.username || 'unknown'; const portfolioExists = await this.portfolioRepoManager.checkPortfolioExists(username); if (!portfolioExists) { logger.info('Creating portfolio repository...'); // Request consent for portfolio creation const repoUrl = await this.portfolioRepoManager.createPortfolio(username, true); if (!repoUrl) { return { success: false, error: { success: false, message: 'Failed to create portfolio repository', error: 'CREATE_FAILED' } }; } } return { success: true }; } /** * Check if content already exists in the portfolio repository * Prevents duplicate uploads by comparing content hashes * @param repoFullName GitHub repository full name (owner/repo) * @param filePath Path to the file in the repository * @param content Content to check against existing * @returns true if identical content exists, false otherwise */ async checkExistingContent(repoFullName, filePath, content, token) { try { // Attempt to fetch existing file from GitHub const url = `https://api.github.com/repos/${repoFullName}/contents/${filePath}`; const response = await fetch(url, { headers: { 'Accept': 'application/vnd.github.v3+json', 'Authorization': `Bearer ${token}`, 'User-Agent': 'DollhouseMCP/1.0' } }); if (response.status === 404) { // File doesn't exist, not a duplicate return false; } if (!response.ok) { logger.warn('Failed to check existing content', { status: response.status, path: filePath }); // On error, allow upload to proceed return false; } const data = await response.json(); // GitHub returns content as base64 const existingContent = Buffer.from(data.content, 'base64').toString('utf-8'); // Compare content hashes const existingHash = createHash('sha256').update(existingContent).digest('hex'); const newHash = createHash('sha256').update(content).digest('hex'); const isDuplicate = existingHash === newHash; if (isDuplicate) { logger.info('Duplicate content detected, skipping upload', { path: filePath, hash: newHash.substring(0, 8) // Log partial hash for debugging }); } return isDuplicate; } catch (error) { logger.warn('Error checking for existing content', { error: error instanceof Error ? error.message : String(error), path: filePath }); // On error, allow upload to proceed rather than blocking return false; } } /** * Check if an issue for this content already exists in the collection * @param elementName Name of the element to check * @param username User submitting the element * @param token GitHub token for API access * @returns URL of existing issue if found, null otherwise */ async checkExistingIssue(elementName, username, token) { try { // Search for existing issues with this element name // Using both title and author to ensure it's the same submission const query = `repo:DollhouseMCP/collection is:issue "${elementName}" in:title author:${username}`; const url = `https://api.github.com/search/issues?q=${encodeURIComponent(query)}&sort=created&order=desc`; const response = await fetch(url, { headers: { 'Accept': 'application/vnd.github.v3+json', 'Authorization': `Bearer ${token}`, 'User-Agent': 'DollhouseMCP/1.0' } }); if (!response.ok) { logger.warn('Failed to search for existing issues', { status: response.status }); return null; } const data = await response.json(); if (data.items && data.items.length > 0) { // Found existing issue(s) const existingIssue = data.items[0]; logger.info('Found existing collection issue', { issueUrl: existingIssue.html_url, elementName }); return existingIssue.html_url; } return null; } catch (error) { logger.warn('Error checking for existing collection issue', { error: