diff --git a/scripts/scan-response-templates.js b/scripts/scan-response-templates.js new file mode 100755 index 00000000..207126dd --- /dev/null +++ b/scripts/scan-response-templates.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +/** + * Scan Response Templates for Governance Violations + * Checks all existing templates for inst_016/017/018/079 violations + * + * Usage: + * node scripts/scan-response-templates.js + * node scripts/scan-response-templates.js --fix # Mark violating templates as inactive + */ + +const { getCollection } = require('../src/utils/db.util'); +const ContentGovernanceChecker = require('../src/services/ContentGovernanceChecker.service'); + +async function main() { + try { + const args = process.argv.slice(2); + const fixMode = args.includes('--fix'); + + console.log('╔══════════════════════════════════════════════════════╗'); + console.log('║ Response Template Governance Scanner ║'); + console.log('╚══════════════════════════════════════════════════════╝'); + console.log(''); + + const collection = await getCollection('response_templates'); + + // Get all active templates + const templates = await collection.find({ active: true }).toArray(); + + console.log(`Found ${templates.length} active templates to scan\n`); + + const results = { + scanned: 0, + passed: 0, + failed: 0, + violations: [] + }; + + for (const template of templates) { + results.scanned++; + + // Scan template content + const fullText = [template.subject, template.content].filter(Boolean).join('\n'); + const check = await ContentGovernanceChecker.scanContent(fullText, { + type: 'response_template', + context: { + template_id: template._id.toString(), + name: template.name, + category: template.category + } + }); + + if (check.success) { + results.passed++; + console.log(`✅ ${template.name} (${template.category})`); + } else { + results.failed++; + console.log(`\n❌ ${template.name} (${template.category})`); + console.log(` ID: ${template._id}`); + console.log(` Violations: ${check.violations.length}`); + + check.violations.forEach((v, idx) => { + console.log(` ${idx + 1}. [${v.severity}] ${v.rule}`); + console.log(` "${v.match}"`); + console.log(` ${v.message}`); + }); + + results.violations.push({ + template_id: template._id, + name: template.name, + category: template.category, + violations: check.violations + }); + + // Update template with governance check results + await collection.updateOne( + { _id: template._id }, + { + $set: { + 'governance_check.passed': false, + 'governance_check.scanned_at': check.scannedAt, + 'governance_check.violations': check.violations, + 'governance_check.summary': check.summary, + updated_at: new Date() + } + } + ); + + // Mark as inactive if in fix mode + if (fixMode) { + await collection.updateOne( + { _id: template._id }, + { + $set: { + active: false, + deactivated_reason: 'Governance violations detected', + deactivated_at: new Date() + } + } + ); + console.log(` 🔒 Template marked as inactive`); + } + + console.log(''); + } + } + + // Summary + console.log(''); + console.log('╔══════════════════════════════════════════════════════╗'); + console.log('║ Scan Summary ║'); + console.log('╚══════════════════════════════════════════════════════╝'); + console.log(''); + console.log(`Total Scanned: ${results.scanned}`); + console.log(`✅ Passed: ${results.passed}`); + console.log(`❌ Failed: ${results.failed}`); + console.log(''); + + if (results.failed > 0) { + console.log('Violations by Rule:'); + const violationsByRule = {}; + results.violations.forEach(v => { + v.violations.forEach(violation => { + violationsByRule[violation.instruction] = (violationsByRule[violation.instruction] || 0) + 1; + }); + }); + + Object.entries(violationsByRule).forEach(([rule, count]) => { + console.log(` • ${rule}: ${count}`); + }); + + console.log(''); + + if (fixMode) { + console.log(`🔒 ${results.failed} violating template(s) marked as inactive`); + } else { + console.log('💡 Run with --fix to mark violating templates as inactive'); + } + } + + console.log(''); + process.exit(results.failed > 0 ? 1 : 0); + + } catch (error) { + console.error('Error scanning templates:', error); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + main(); +} + +module.exports = { main }; diff --git a/src/controllers/media.controller.js b/src/controllers/media.controller.js index f0854e49..ea619b08 100644 --- a/src/controllers/media.controller.js +++ b/src/controllers/media.controller.js @@ -8,6 +8,7 @@ const ModerationQueue = require('../models/ModerationQueue.model'); const GovernanceLog = require('../models/GovernanceLog.model'); const BoundaryEnforcer = require('../services/BoundaryEnforcer.service'); const MediaTriageService = require('../services/MediaTriage.service'); +const ContentGovernanceChecker = require('../services/ContentGovernanceChecker.service'); const logger = require('../utils/logger.util'); /** @@ -242,9 +243,39 @@ async function respondToInquiry(req, res) { }); } + // Framework governance check (inst_016, inst_017, inst_018, inst_079) + const governanceCheck = await ContentGovernanceChecker.scanContent(content, { + type: 'media_response', + context: { + inquiry_id: id, + outlet: inquiry.contact?.outlet, + responder: req.user.email + } + }); + + if (!governanceCheck.success) { + logger.warn(`Media response blocked due to governance violations`, { + inquiry_id: id, + violations: governanceCheck.violations, + responder: req.user.email + }); + + return res.status(400).json({ + error: 'Governance Violations Detected', + message: 'Response content violates framework governance rules', + violations: governanceCheck.violations, + summary: governanceCheck.summary, + blocked_by: 'ContentGovernanceChecker' + }); + } + const success = await MediaInquiry.respond(id, { content, - responder: req.user.email + responder: req.user.email, + governance_check: { + passed: true, + scanned_at: governanceCheck.scannedAt + } }); if (!success) { @@ -254,12 +285,18 @@ async function respondToInquiry(req, res) { }); } - logger.info(`Media inquiry ${id} responded to by ${req.user.email}`); + logger.info(`Media inquiry ${id} responded to by ${req.user.email}`, { + governance_check_passed: true + }); res.json({ success: true, - message: 'Response recorded successfully', - note: 'Remember to send actual email to media contact separately' + message: 'Response recorded successfully (framework check passed)', + note: 'Remember to send actual email to media contact separately', + governance: { + checked: true, + violations: 0 + } }); } catch (error) { diff --git a/src/models/ResponseTemplate.model.js b/src/models/ResponseTemplate.model.js index 16916f6d..4089d5bb 100644 --- a/src/models/ResponseTemplate.model.js +++ b/src/models/ResponseTemplate.model.js @@ -1,18 +1,34 @@ /** * ResponseTemplate Model * Pre-written response templates for common inquiries + * + * Framework enforcement: All templates scanned for governance violations + * (inst_016, inst_017, inst_018, inst_079) */ const { ObjectId } = require('mongodb'); const { getCollection } = require('../utils/db.util'); +const ContentGovernanceChecker = require('../services/ContentGovernanceChecker.service'); class ResponseTemplate { /** * Create a new response template + * Includes framework governance check (inst_016, inst_017, inst_018, inst_079) */ static async create(data) { const collection = await getCollection('response_templates'); + // Framework governance check on template content + const fullText = [data.subject, data.content].filter(Boolean).join('\n'); + const governanceCheck = await ContentGovernanceChecker.scanContent(fullText, { + type: 'response_template', + context: { + name: data.name, + category: data.category, + created_by: data.created_by + } + }); + const template = { // Template identification name: data.name, @@ -37,6 +53,14 @@ class ResponseTemplate { visibility: data.visibility || 'public', // public, private, team created_by: data.created_by ? new ObjectId(data.created_by) : null, + // Framework governance check results + governance_check: { + passed: governanceCheck.success, + scanned_at: governanceCheck.scannedAt, + violations: governanceCheck.violations || [], + summary: governanceCheck.summary + }, + // Usage statistics usage_stats: { times_used: 0, @@ -51,7 +75,11 @@ class ResponseTemplate { }; const result = await collection.insertOne(template); - return { ...template, _id: result.insertedId }; + return { + ...template, + _id: result.insertedId, + governance_check: governanceCheck // Return full check results + }; } /** diff --git a/src/services/ContentGovernanceChecker.service.js b/src/services/ContentGovernanceChecker.service.js new file mode 100644 index 00000000..65966e6e --- /dev/null +++ b/src/services/ContentGovernanceChecker.service.js @@ -0,0 +1,182 @@ +/** + * Content Governance Checker Service + * Unified framework checker for all external communications + * + * Used by: + * - Blog posts (before publication) + * - Media inquiry responses (before sending) + * - Response templates (on create/update) + * - Newsletter content (future) + * + * Enforces: + * - inst_016: No fabricated statistics without citation + * - inst_017: No absolute guarantees + * - inst_018: No unverified production claims + * - inst_079: No dark patterns or manipulative urgency + */ + +// Pattern definitions from governance rules +const contentPatterns = { + inst_016_fabricated_stats: { + patterns: [ + /\b\d+%\s+(?:faster|better|improvement|increase|decrease|reduction|more|less)\b(?!\s*\[NEEDS VERIFICATION\]|\s*\(source:|\s*\[source:)/gi, + /\b(?:faster|better|improvement)\s+of\s+\d+%\b(?!\s*\[NEEDS VERIFICATION\]|\s*\(source:|\s*\[source:)/gi, + /\b\d+x\s+(?:faster|better|more|increase)\b(?!\s*\[NEEDS VERIFICATION\]|\s*\(source:|\s*\[source:)/gi + ], + severity: 'HIGH', + message: 'Statistics require citation or [NEEDS VERIFICATION] marker (inst_016)', + instruction: 'inst_016' + }, + inst_017_absolute_guarantees: { + patterns: [ + /\bguarantee(?:s|d|ing)?\b/gi, + /\b100%\s+(?:secure|safe|reliable|effective)\b/gi, + /\bcompletely\s+prevents?\b/gi, + /\bnever\s+fails?\b/gi, + /\balways\s+works?\b/gi, + /\beliminates?\s+all\b/gi, + /\bperfect(?:ly)?\s+(?:secure|safe|reliable)\b/gi + ], + severity: 'HIGH', + message: 'Absolute guarantees prohibited - use evidence-based language (inst_017)', + instruction: 'inst_017' + }, + inst_018_unverified_claims: { + patterns: [ + /\bproduction-ready\b(?!\s+development\s+tool|\s+proof-of-concept)/gi, + /\bbattle-tested\b/gi, + /\benterprise-proven\b/gi, + /\bwidespread\s+adoption\b/gi, + /\bindustry-standard\b/gi + ], + severity: 'MEDIUM', + message: 'Production claims require evidence (this is proof-of-concept) (inst_018)', + instruction: 'inst_018' + }, + inst_079_dark_patterns: { + patterns: [ + /\bclick\s+here\s+now\b/gi, + /\blimited\s+time\s+offer\b/gi, + /\bonly\s+\d+\s+spots?\s+left\b/gi, + /\bact\s+fast\b/gi, + /\bhurry\b.*\b(?:before|while)\b/gi, + /\bdon't\s+miss\s+out\b/gi + ], + severity: 'MEDIUM', + message: 'Possible manipulative urgency/dark pattern detected (inst_079)', + instruction: 'inst_079' + } +}; + +/** + * Scan content for governance violations + * @param {string} text - Content to scan + * @param {object} options - Scanning options + * @returns {Promise} - Scan results with violations array + */ +async function scanContent(text, options = {}) { + const { + type = 'general', // blog, media_response, template, newsletter + context = {} + } = options; + + if (!text || typeof text !== 'string') { + return { + success: true, + violations: [], + scannedAt: new Date(), + type, + context + }; + } + + const violations = []; + + // Scan for each pattern + for (const [ruleId, config] of Object.entries(contentPatterns)) { + for (const pattern of config.patterns) { + const matches = text.match(pattern); + if (matches) { + // Get unique matches only + const uniqueMatches = [...new Set(matches)]; + + uniqueMatches.forEach(match => { + violations.push({ + rule: ruleId, + instruction: config.instruction, + severity: config.severity, + match, + message: config.message, + position: text.indexOf(match), + context: getMatchContext(text, text.indexOf(match)) + }); + }); + } + } + } + + return { + success: violations.length === 0, + violations, + scannedAt: new Date(), + type, + context, + summary: { + total: violations.length, + high: violations.filter(v => v.severity === 'HIGH').length, + medium: violations.filter(v => v.severity === 'MEDIUM').length, + byRule: violations.reduce((acc, v) => { + acc[v.instruction] = (acc[v.instruction] || 0) + 1; + return acc; + }, {}) + } + }; +} + +/** + * Get context around a match (50 chars before and after) + */ +function getMatchContext(text, position, contextLength = 50) { + const start = Math.max(0, position - contextLength); + const end = Math.min(text.length, position + contextLength); + + let context = text.substring(start, end); + + // Add ellipsis if truncated + if (start > 0) context = '...' + context; + if (end < text.length) context = context + '...'; + + return context; +} + +/** + * Format violations for display + */ +function formatViolations(violations) { + if (!violations || violations.length === 0) { + return 'No violations found'; + } + + return violations.map((v, idx) => + `${idx + 1}. [${v.severity}] ${v.rule}\n` + + ` Match: "${v.match}"\n` + + ` ${v.message}\n` + + ` Context: ${v.context}` + ).join('\n\n'); +} + +/** + * Check if content is safe for publication + * Convenience method that returns boolean + */ +async function isSafeForPublication(text, options = {}) { + const result = await scanContent(text, options); + return result.success; +} + +module.exports = { + scanContent, + formatViolations, + isSafeForPublication, + contentPatterns +};