@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
JavaScript
/**
* 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