UNPKG

@jmkim85/dev-flow-mcp

Version:

MCP-based Dev Flow - AI-powered development workflow management with 13 essential tools for TDD and context management

443 lines 18.6 kB
/** * Reward Hacking Detector for Dev Flow MCP v2.0 * Prevents LLM from gaming the system or bypassing requirements */ import { promises as fs } from 'fs'; import { join } from 'path'; import YAML from 'yaml'; export class RewardHackingDetector { projectRoot; suspiciousPatterns = [ /assert.*True.*always/i, /test.*pass.*skip/i, /return.*success.*mock/i, /TODO.*later.*fake/i, /completion.*100.*hardcode/i, /\.skip\(\)/i, // Jest/Mocha test skipping /xit\(/i, // Jest disabled tests /xdescribe\(/i, // Jest disabled test suites /pending\(/i, // Jasmine pending tests /assert\s+True\s*$/i, // Always true assertions /pass\s*#.*temporary/i, /return\s+True\s*#/i ]; trivialSubtaskPatterns = [ /^(update|fix|adjust)\s+(comment|documentation|typo)/i, /^(add|create)\s+placeholder/i, /^temporarily\s+/i, /^quick\s+fix/i, /^minor\s+/i, /^simple\s+/i, /^just\s+/i, /^only\s+/i ]; constructor(projectRoot) { this.projectRoot = projectRoot; } async checkSubtaskCreation(context) { // Prevent creating trivial subtasks to bypass requirements for (const pattern of this.trivialSubtaskPatterns) { if (pattern.test(context.proposed_title)) { return { allowed: false, reason: `Sub-task appears trivial or temporary: "${context.proposed_title}". Focus on substantial work.` }; } } // Check if reason is suspicious const suspiciousReasons = [ /avoid.*test/i, /bypass.*requirement/i, /skip.*validation/i, /quick.*workaround/i, /temporary.*solution/i ]; for (const pattern of suspiciousReasons) { if (pattern.test(context.reason)) { return { allowed: false, reason: `Sub-task reason appears to bypass requirements: "${context.reason}"` }; } } // Check if too many subtasks (possible procrastination) const tasks = await this.loadTasks(); const siblingSubtasks = tasks.filter(t => t.parent_task_id === context.parent_task.id); if (siblingSubtasks.length >= 5) { return { allowed: false, reason: `Too many sub-tasks (${siblingSubtasks.length}). Complete existing work first.` }; } // Check if subtask is too similar to existing ones const similarTasks = siblingSubtasks.filter(t => this.calculateSimilarity(t.title, context.proposed_title) > 0.7); if (similarTasks.length > 0) { return { allowed: false, reason: `Sub-task too similar to existing task: "${similarTasks[0].title}"` }; } return { allowed: true }; } async checkTestModification(filePath, oldContent, newContent, failFast = false) { const foundPatterns = []; let severity = 'LOW'; let criticalViolations = 0; // Enhanced suspicious patterns with severity levels const criticalPatterns = [ { pattern: /\.skip\(\)/i, name: 'Test skipping (.skip())', severity: 'HIGH' }, { pattern: /xit\(/i, name: 'Disabled test (xit)', severity: 'HIGH' }, { pattern: /xdescribe\(/i, name: 'Disabled test suite (xdescribe)', severity: 'HIGH' }, { pattern: /assert\s+True\s*$/i, name: 'Always true assertion', severity: 'HIGH' }, { pattern: /return\s+True\s*#/i, name: 'Hardcoded true return', severity: 'HIGH' } ]; const mediumPatterns = [ { pattern: /assert.*True.*always/i, name: 'Suspicious always-true assertion', severity: 'MEDIUM' }, { pattern: /test.*pass.*skip/i, name: 'Test pass with skip mention', severity: 'MEDIUM' }, { pattern: /return.*success.*mock/i, name: 'Mocked success return', severity: 'MEDIUM' }, { pattern: /pending\(/i, name: 'Pending test', severity: 'MEDIUM' } ]; const lowPatterns = [ { pattern: /TODO.*later.*fake/i, name: 'TODO with fake implementation', severity: 'LOW' }, { pattern: /completion.*100.*hardcode/i, name: 'Hardcoded completion', severity: 'LOW' }, { pattern: /pass\s*#.*temporary/i, name: 'Temporary pass comment', severity: 'LOW' } ]; // Check all pattern categories const allPatterns = [...criticalPatterns, ...mediumPatterns, ...lowPatterns]; for (const { pattern, name, severity: patternSeverity } of allPatterns) { if (pattern.test(newContent) && !pattern.test(oldContent)) { foundPatterns.push(name); if (patternSeverity === 'HIGH') { criticalViolations++; severity = 'HIGH'; } else if (patternSeverity === 'MEDIUM' && severity !== 'HIGH') { severity = 'MEDIUM'; } } } // Check if tests were significantly weakened const oldAsserts = this.countAssertions(oldContent); const newAsserts = this.countAssertions(newContent); if (newAsserts < oldAsserts) { const reductionPercentage = ((oldAsserts - newAsserts) / oldAsserts) * 100; if (reductionPercentage > 50) { foundPatterns.push(`Reduced assertions by ${reductionPercentage.toFixed(0)}%`); criticalViolations++; severity = 'HIGH'; } else if (reductionPercentage > 25) { foundPatterns.push(`Reduced assertions by ${reductionPercentage.toFixed(0)}%`); if (severity !== 'HIGH') severity = 'MEDIUM'; } } // Check if test cases were removed const oldTestCases = this.countTestCases(oldContent); const newTestCases = this.countTestCases(newContent); if (newTestCases < oldTestCases) { const testReduction = oldTestCases - newTestCases; if (testReduction > 2) { foundPatterns.push(`Removed ${testReduction} test cases`); criticalViolations++; severity = 'HIGH'; } else { foundPatterns.push(`Removed ${testReduction} test case(s)`); if (severity !== 'HIGH') severity = 'MEDIUM'; } } // Enhanced hardcoded success detection const hardcodedPatterns = [ /return\s+true\s*;?\s*\/\/.*always/i, /status\s*=\s*"success"\s*;?\s*\/\/.*hardcode/i, /result\s*=\s*"pass"\s*;?\s*\/\/.*fake/i, /expect\(true\)\.toBe\(true\)/i, /assert\.isTrue\(true\)/i, /assertTrue\(true\)/i ]; for (const pattern of hardcodedPatterns) { if (pattern.test(newContent) && !pattern.test(oldContent)) { foundPatterns.push('Hardcoded success values'); criticalViolations++; severity = 'HIGH'; } } // Determine if we should fail fast const shouldFail = failFast && (criticalViolations > 0 || severity === 'HIGH'); // If fail fast is enabled and we have critical violations, exit immediately if (shouldFail) { console.error('🚫 CRITICAL REWARD HACKING DETECTED - FAILING FAST'); console.error(`File: ${filePath}`); console.error(`Patterns: ${foundPatterns.join(', ')}`); console.error(`Severity: ${severity}`); // Exit the process to prevent further execution process.exit(1); } return { suspicious: foundPatterns.length > 0, patterns: foundPatterns, shouldFail, severity }; } async checkTaskCompletion(task, context) { // Check if completion reason is suspicious const suspiciousCompletionReasons = [ /skip.*test/i, /ignore.*requirement/i, /good.*enough/i, /temporary.*complete/i, /will.*fix.*later/i ]; for (const pattern of suspiciousCompletionReasons) { if (pattern.test(context.completion_reason)) { return { allowed: false, reason: `Suspicious completion reason: "${context.completion_reason}"` }; } } // Check if tests are actually passing if (context.test_results && !context.test_results.all_passed) { return { allowed: false, reason: `Cannot complete task with failing tests: ${context.test_results.failures.join(', ')}` }; } // Check if any files were actually modified if (context.files_modified.length === 0) { return { allowed: false, reason: 'No files were modified. Task completion appears premature.' }; } // Check for minimal effort (very few changes) const totalChanges = await this.estimateChangesInFiles(context.files_modified); if (totalChanges < 5) { return { allowed: false, reason: `Very few changes detected (${totalChanges} lines). Ensure task is truly complete.` }; } return { allowed: true }; } async checkStageAdvancement(currentStage, nextStage, context) { // Check if advancing too quickly if (context.time_in_stage < 2) { return { allowed: false, reason: `Stage advancement too quick (${context.time_in_stage} minutes). Spend more time on current stage.` }; } // Check if required outputs were created const requiredOutputs = this.getRequiredOutputsForStage(currentStage); const missingOutputs = requiredOutputs.filter(output => !context.outputs_created.includes(output)); if (missingOutputs.length > 0) { return { allowed: false, reason: `Missing required outputs: ${missingOutputs.join(', ')}` }; } // Check iteration limits const iterations = context.stage_iterations[currentStage] || 0; const maxIterations = this.getMaxIterationsForStage(currentStage); if (iterations >= maxIterations) { return { allowed: false, reason: `Maximum iterations (${maxIterations}) reached for stage ${currentStage}. Review your approach.` }; } return { allowed: true }; } /** * Enhanced real-time test file monitoring with fail fast */ async monitorTestFiles(testFilePaths, failFast = true) { const violations = []; let totalViolations = 0; let criticalViolations = 0; for (const filePath of testFilePaths) { try { const fullPath = join(this.projectRoot, filePath); const content = await fs.readFile(fullPath, 'utf-8'); // Check against empty content to detect any suspicious patterns const result = await this.checkTestModification(filePath, '', content, failFast); if (result.suspicious) { violations.push({ file: filePath, patterns: result.patterns, severity: result.severity || 'LOW' }); totalViolations++; if (result.severity === 'HIGH') { criticalViolations++; } } } catch (error) { // File might not exist or be inaccessible console.warn(`Warning: Could not monitor test file ${filePath}:`, error); } } return { violations, totalViolations, criticalViolations }; } /** * Quick check for common test bypassing patterns */ async quickTestBypassCheck(content) { const bypassPatterns = []; let riskLevel = 'LOW'; // Critical bypass patterns const criticalBypasses = [ { pattern: /\.skip\(\)/g, name: 'Test skipping' }, { pattern: /xit\(/g, name: 'Disabled tests' }, { pattern: /xdescribe\(/g, name: 'Disabled test suites' }, { pattern: /expect\(true\)\.toBe\(true\)/g, name: 'Trivial assertions' }, { pattern: /assert\(true\)/g, name: 'Always true assertions' } ]; // Medium risk patterns const mediumBypasses = [ { pattern: /pending\(/g, name: 'Pending tests' }, { pattern: /TODO.*test/gi, name: 'TODO test placeholders' }, { pattern: /mock.*return.*true/gi, name: 'Mocked success returns' } ]; // Check critical patterns for (const { pattern, name } of criticalBypasses) { const matches = content.match(pattern); if (matches && matches.length > 0) { bypassPatterns.push(`${name} (${matches.length} occurrences)`); riskLevel = 'HIGH'; } } // Check medium patterns only if no critical patterns found if (riskLevel !== 'HIGH') { for (const { pattern, name } of mediumBypasses) { const matches = content.match(pattern); if (matches && matches.length > 0) { bypassPatterns.push(`${name} (${matches.length} occurrences)`); riskLevel = 'MEDIUM'; } } } return { hasBypass: bypassPatterns.length > 0, bypassPatterns, riskLevel }; } /** * Generate detailed report of test integrity issues */ async generateTestIntegrityReport(testFilePaths) { const monitoring = await this.monitorTestFiles(testFilePaths, false); if (monitoring.totalViolations === 0) { return '✅ **Test Integrity Report**: All test files appear clean and legitimate.'; } let report = `🚨 **Test Integrity Report**\n\n`; report += `**Summary**: ${monitoring.totalViolations} violations found (${monitoring.criticalViolations} critical)\n\n`; for (const violation of monitoring.violations) { const riskEmoji = violation.severity === 'HIGH' ? '🔴' : violation.severity === 'MEDIUM' ? '🟡' : '🟢'; report += `${riskEmoji} **${violation.file}** (${violation.severity} risk)\n`; for (const pattern of violation.patterns) { report += ` - ${pattern}\n`; } report += '\n'; } if (monitoring.criticalViolations > 0) { report += `⚠️ **CRITICAL**: ${monitoring.criticalViolations} high-risk violations detected. These patterns suggest potential test bypassing or gaming.\n\n`; report += `**Recommended Actions**:\n`; report += `1. Review and fix all HIGH severity violations immediately\n`; report += `2. Ensure tests are meaningful and properly validate functionality\n`; report += `3. Remove any test skipping or hardcoded success patterns\n`; } return report; } async loadTasks() { try { const tasksPath = join(this.projectRoot, '.devflow', 'tasks.yaml'); const data = await fs.readFile(tasksPath, 'utf-8'); const parsed = YAML.parse(data); // JSON.parse → YAML.parse return parsed.tasks || []; } catch (error) { return []; } } calculateSimilarity(str1, str2) { const words1 = str1.toLowerCase().split(/\s+/); const words2 = str2.toLowerCase().split(/\s+/); const intersection = words1.filter(word => words2.includes(word)); const union = [...new Set([...words1, ...words2])]; return intersection.length / union.length; } countAssertions(content) { const assertPatterns = [ /assert/gi, /expect/gi, /should/gi, /\.toBe/gi, /\.toEqual/gi, /\.toMatch/gi ]; return assertPatterns.reduce((count, pattern) => { const matches = content.match(pattern); return count + (matches ? matches.length : 0); }, 0); } countTestCases(content) { const testPatterns = [ /it\s*\(/gi, /test\s*\(/gi, /describe\s*\(/gi, /def\s+test_/gi // Python ]; return testPatterns.reduce((count, pattern) => { const matches = content.match(pattern); return count + (matches ? matches.length : 0); }, 0); } async estimateChangesInFiles(filePaths) { // This is a simplified estimation // In a real implementation, you might use git diff or file comparison let totalLines = 0; for (const filePath of filePaths) { try { const fullPath = join(this.projectRoot, filePath); const content = await fs.readFile(fullPath, 'utf-8'); totalLines += content.split('\n').length; } catch (error) { // File might not exist or be accessible } } return Math.min(totalLines, 100); // Cap at 100 for estimation } getRequiredOutputsForStage(stage) { const stageOutputs = { 'analyze': ['analysis/analysis.md'], 'design': ['design/architecture.md'], 'write_tests': ['tests/test_plan.md'], 'implement': ['implementation/implementation.md'], 'refactor': ['refactor/refactor_plan.md'] }; return stageOutputs[stage] || []; } getMaxIterationsForStage(stage) { const stageIterations = { 'understand': 2, 'design_tests': 3, 'implement': 5, 'refactor': 3, 'analyze': 2, 'review': 2 }; return stageIterations[stage] || 3; } } //# sourceMappingURL=reward-hacking-detector.js.map