shipdeck
Version:
Ship MVPs in 48 hours. Fix bugs in 30 seconds. The command deck for developers who ship.
763 lines (642 loc) ⢠23.5 kB
JavaScript
/**
* Tier 3 Human Approval Workflow
*
* Handles 12 critical Tier 3 violations requiring human approval:
* - Architecture changes
* - Security vulnerabilities
* - Breaking changes
* - Database migrations
* - Performance regressions
*
* Provides clear risk assessment and approval interfaces.
*/
const EventEmitter = require('events');
class TierThreeApproval extends EventEmitter {
constructor(config = {}) {
super();
this.config = {
// Approval settings
approvalTimeout: config.approvalTimeout || 3600000, // 1 hour
requireMultipleApprovers: config.requireMultipleApprovers || false,
minimumApprovers: config.minimumApprovers || 1,
allowSelfApproval: config.allowSelfApproval || false,
// Risk assessment
riskThresholds: {
low: config.riskThresholds?.low || 0.3,
medium: config.riskThresholds?.medium || 0.6,
high: config.riskThresholds?.high || 0.8,
critical: config.riskThresholds?.critical || 0.9
},
// Notification settings
notificationChannels: config.notificationChannels || ['console'],
escalationDelay: config.escalationDelay || 1800000, // 30 minutes
...config
};
// Active approval requests
this.pendingApprovals = new Map();
this.approvalHistory = [];
// Risk assessment models
this.riskAssessors = this._initializeRiskAssessors();
// Statistics
this.stats = {
totalRequests: 0,
approvedRequests: 0,
deniedRequests: 0,
timedOutRequests: 0,
averageApprovalTime: 0,
riskDistribution: { low: 0, medium: 0, high: 0, critical: 0 }
};
}
/**
* Request human approval for critical violations
*/
async requestApproval(artifact, violations, context = {}) {
const startTime = Date.now();
const approvalId = this._generateApprovalId();
console.log(`ā ļø Requesting human approval for ${violations.length} critical violations: ${approvalId}`);
try {
// Perform comprehensive risk assessment
const riskAssessment = await this._performRiskAssessment(artifact, violations, context);
// Create approval request
const approvalRequest = {
id: approvalId,
artifact,
violations,
context,
riskAssessment,
createdAt: startTime,
status: 'pending',
approvers: [],
comments: [],
expiresAt: startTime + this.config.approvalTimeout
};
this.pendingApprovals.set(approvalId, approvalRequest);
this.stats.totalRequests++;
// Generate approval presentation
const approvalPresentation = this._generateApprovalPresentation(approvalRequest);
// Send notifications
await this._sendApprovalNotifications(approvalRequest, approvalPresentation);
// Log approval request
console.log(`š Approval request created: ${approvalId}`);
console.log(` Risk Level: ${riskAssessment.level.toUpperCase()}`);
console.log(` Violations: ${violations.map(v => v.rule.name).join(', ')}`);
this.emit('approval:requested', {
approvalId,
riskLevel: riskAssessment.level,
violationCount: violations.length,
expiresAt: approvalRequest.expiresAt
});
// For automated testing or immediate processing, check for auto-approval conditions
if (this._shouldAutoProcess(riskAssessment, context)) {
return this._processAutoApproval(approvalRequest);
}
// Return pending status for manual approval
return {
success: false,
requiresApproval: true,
approvalId,
riskAssessment,
approvalPresentation,
expiresAt: approvalRequest.expiresAt,
artifact,
approvalCount: 0,
deniedCount: 0,
pendingCount: 1,
processingTime: Date.now() - startTime
};
} catch (error) {
console.error(`ā Approval request failed: ${error.message}`);
this.emit('approval:failed', {
approvalId,
error: error.message
});
return {
success: false,
error: error.message,
artifact,
approvalCount: 0,
deniedCount: 1,
pendingCount: 0
};
}
}
/**
* Submit approval decision
*/
async submitApprovalDecision(approvalId, decision) {
const approval = this.pendingApprovals.get(approvalId);
if (!approval) {
throw new Error(`Approval request not found: ${approvalId}`);
}
if (approval.status !== 'pending') {
throw new Error(`Approval request already processed: ${approvalId} (${approval.status})`);
}
if (Date.now() > approval.expiresAt) {
approval.status = 'expired';
this.stats.timedOutRequests++;
throw new Error(`Approval request expired: ${approvalId}`);
}
// Validate decision
const validatedDecision = this._validateDecision(decision, approval);
// Record the decision
approval.approvers.push({
approver: validatedDecision.approver,
decision: validatedDecision.approved,
reason: validatedDecision.reason,
timestamp: Date.now(),
modifications: validatedDecision.modifications || []
});
if (validatedDecision.comment) {
approval.comments.push({
author: validatedDecision.approver,
comment: validatedDecision.comment,
timestamp: Date.now()
});
}
// Check if we have enough approvers
const approvedCount = approval.approvers.filter(a => a.decision === true).length;
const deniedCount = approval.approvers.filter(a => a.decision === false).length;
let finalDecision = null;
if (deniedCount > 0) {
// Any denial blocks approval
approval.status = 'denied';
finalDecision = 'denied';
this.stats.deniedRequests++;
} else if (approvedCount >= this.config.minimumApprovers) {
// Sufficient approvals
approval.status = 'approved';
finalDecision = 'approved';
this.stats.approvedRequests++;
}
if (finalDecision) {
approval.completedAt = Date.now();
const approvalTime = approval.completedAt - approval.createdAt;
this._updateAverageApprovalTime(approvalTime);
// Move to history
this.approvalHistory.push(approval);
this.pendingApprovals.delete(approvalId);
console.log(`ā
Approval ${finalDecision}: ${approvalId} (${approvalTime}ms)`);
this.emit('approval:completed', {
approvalId,
decision: finalDecision,
approvalTime,
approverCount: approval.approvers.length
});
return {
success: finalDecision === 'approved',
decision: finalDecision,
approvalId,
artifact: this._applyApprovedModifications(approval),
modifications: this._collectModifications(approval),
approvers: approval.approvers,
comments: approval.comments
};
}
// Still pending more approvers
console.log(`ā³ Approval pending: ${approvalId} (${approvedCount}/${this.config.minimumApprovers} approvers)`);
return {
success: false,
pending: true,
approvalId,
approvedCount,
deniedCount,
requiredApprovers: this.config.minimumApprovers
};
}
/**
* Get approval status
*/
getApprovalStatus(approvalId) {
const approval = this.pendingApprovals.get(approvalId);
if (!approval) {
// Check history
const historical = this.approvalHistory.find(a => a.id === approvalId);
if (historical) {
return {
found: true,
status: historical.status,
completed: true,
approvers: historical.approvers,
comments: historical.comments
};
}
return { found: false };
}
return {
found: true,
status: approval.status,
pending: approval.status === 'pending',
expired: Date.now() > approval.expiresAt,
riskLevel: approval.riskAssessment.level,
violationCount: approval.violations.length,
approvers: approval.approvers,
comments: approval.comments,
expiresAt: approval.expiresAt,
timeRemaining: Math.max(0, approval.expiresAt - Date.now())
};
}
/**
* Cancel pending approval
*/
cancelApproval(approvalId, reason = 'Cancelled') {
const approval = this.pendingApprovals.get(approvalId);
if (!approval) {
throw new Error(`Approval request not found: ${approvalId}`);
}
approval.status = 'cancelled';
approval.cancellationReason = reason;
approval.completedAt = Date.now();
this.approvalHistory.push(approval);
this.pendingApprovals.delete(approvalId);
console.log(`š« Approval cancelled: ${approvalId} - ${reason}`);
this.emit('approval:cancelled', {
approvalId,
reason
});
}
/**
* Perform comprehensive risk assessment
*/
async _performRiskAssessment(artifact, violations, context) {
console.log('š Performing risk assessment...');
const assessments = await Promise.allSettled([
this._assessSecurityRisk(violations, context),
this._assessBreakingChangeRisk(violations, context),
this._assessPerformanceRisk(violations, context),
this._assessArchitecturalRisk(violations, context),
this._assessDataIntegrityRisk(violations, context)
]);
const risks = assessments.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
} else {
console.warn(`Risk assessment ${index} failed: ${result.reason}`);
return { score: 0.5, factors: ['assessment_failed'] };
}
});
// Calculate overall risk score (weighted average)
const weights = [0.3, 0.25, 0.2, 0.15, 0.1]; // Security gets highest weight
const overallScore = risks.reduce((sum, risk, index) => sum + (risk.score * weights[index]), 0);
// Determine risk level
let level = 'low';
if (overallScore >= this.config.riskThresholds.critical) level = 'critical';
else if (overallScore >= this.config.riskThresholds.high) level = 'high';
else if (overallScore >= this.config.riskThresholds.medium) level = 'medium';
// Collect all risk factors
const allFactors = risks.reduce((factors, risk) => [...factors, ...risk.factors], []);
const uniqueFactors = [...new Set(allFactors)];
// Update statistics
this.stats.riskDistribution[level]++;
const assessment = {
level,
score: overallScore,
factors: uniqueFactors,
breakdown: {
security: risks[0],
breakingChanges: risks[1],
performance: risks[2],
architectural: risks[3],
dataIntegrity: risks[4]
},
recommendations: this._generateRiskRecommendations(overallScore, uniqueFactors),
mitigations: this._suggestMitigations(violations, uniqueFactors)
};
console.log(`š Risk assessment complete: ${level.toUpperCase()} (${Math.round(overallScore * 100)}%)`);
return assessment;
}
// Risk assessment implementations
async _assessSecurityRisk(violations, context) {
const securityViolations = violations.filter(v =>
v.rule.category === 'security' || v.rule.severity === 'critical'
);
const factors = [];
let score = 0;
securityViolations.forEach(violation => {
switch (violation.rule.name) {
case 'no-secrets-in-code':
score += 0.8;
factors.push('hardcoded_secrets');
break;
case 'auth-middleware':
score += 0.6;
factors.push('missing_authentication');
break;
case 'csrf-protection':
score += 0.4;
factors.push('csrf_vulnerability');
break;
case 'prepared-statements':
score += 0.7;
factors.push('sql_injection_risk');
break;
default:
score += 0.3;
factors.push('security_violation');
}
});
return {
score: Math.min(1, score),
factors: factors.length > 0 ? factors : ['no_security_issues']
};
}
async _assessBreakingChangeRisk(violations, context) {
const breakingViolations = violations.filter(v =>
v.rule.name.includes('breaking') ||
v.rule.description.toLowerCase().includes('breaking')
);
const factors = [];
let score = 0;
if (breakingViolations.length > 0) {
score = 0.8;
factors.push('api_breaking_changes');
}
// Check for database schema changes
if (violations.some(v => v.rule.name.includes('migration'))) {
score = Math.max(score, 0.6);
factors.push('database_schema_changes');
}
return {
score,
factors: factors.length > 0 ? factors : ['no_breaking_changes']
};
}
async _assessPerformanceRisk(violations, context) {
const performanceViolations = violations.filter(v =>
v.rule.category === 'performance' || v.rule.severity === 'performance'
);
let score = performanceViolations.length * 0.2;
const factors = performanceViolations.map(v => v.rule.name);
return {
score: Math.min(1, score),
factors: factors.length > 0 ? factors : ['no_performance_issues']
};
}
async _assessArchitecturalRisk(violations, context) {
const archViolations = violations.filter(v =>
v.rule.category === 'architecture' ||
v.rule.name.includes('architecture')
);
let score = archViolations.length * 0.3;
const factors = archViolations.map(v => v.rule.name);
return {
score: Math.min(1, score),
factors: factors.length > 0 ? factors : ['no_architectural_issues']
};
}
async _assessDataIntegrityRisk(violations, context) {
const dataViolations = violations.filter(v =>
v.rule.category === 'database' && v.rule.severity === 'critical'
);
let score = dataViolations.length * 0.4;
const factors = dataViolations.map(v => v.rule.name);
return {
score: Math.min(1, score),
factors: factors.length > 0 ? factors : ['no_data_integrity_issues']
};
}
/**
* Generate approval presentation
*/
_generateApprovalPresentation(approvalRequest) {
const { violations, riskAssessment, context } = approvalRequest;
return {
title: `Quality Gate Approval Required - ${riskAssessment.level.toUpperCase()} Risk`,
summary: {
violationCount: violations.length,
riskLevel: riskAssessment.level,
riskScore: Math.round(riskAssessment.score * 100),
expiresIn: Math.round((approvalRequest.expiresAt - Date.now()) / 60000) // minutes
},
violations: violations.map(v => ({
rule: v.rule.name,
category: v.rule.category,
severity: v.rule.severity,
message: v.message,
suggestion: v.suggestion,
location: v.position ? `Line ${v.position}` : 'Unknown'
})),
riskFactors: riskAssessment.factors,
recommendations: riskAssessment.recommendations,
mitigations: riskAssessment.mitigations,
codePreview: this._generateCodePreview(approvalRequest.artifact, violations),
approvalActions: this._generateApprovalActions(riskAssessment)
};
}
/**
* Send approval notifications
*/
async _sendApprovalNotifications(approvalRequest, presentation) {
const { id, riskAssessment } = approvalRequest;
// Console notification (always enabled)
console.log(`\nš APPROVAL REQUIRED: ${id}`);
console.log(` Risk Level: ${riskAssessment.level.toUpperCase()}`);
console.log(` Violations: ${approvalRequest.violations.length}`);
console.log(` Expires: ${new Date(approvalRequest.expiresAt).toLocaleString()}`);
console.log(` View details: http://localhost:3000/approvals/${id}\n`);
// Additional notification channels would be implemented here
for (const channel of this.config.notificationChannels) {
try {
await this._sendNotificationToChannel(channel, approvalRequest, presentation);
} catch (error) {
console.warn(`ā ļø Failed to send notification to ${channel}: ${error.message}`);
}
}
}
async _sendNotificationToChannel(channel, approvalRequest, presentation) {
switch (channel) {
case 'email':
// await this._sendEmailNotification(approvalRequest, presentation);
break;
case 'slack':
// await this._sendSlackNotification(approvalRequest, presentation);
break;
case 'webhook':
// await this._sendWebhookNotification(approvalRequest, presentation);
break;
case 'console':
// Already handled above
break;
}
}
// Utility methods
_initializeRiskAssessors() {
return {
security: this._assessSecurityRisk.bind(this),
breakingChanges: this._assessBreakingChangeRisk.bind(this),
performance: this._assessPerformanceRisk.bind(this),
architectural: this._assessArchitecturalRisk.bind(this),
dataIntegrity: this._assessDataIntegrityRisk.bind(this)
};
}
_generateApprovalId() {
return `approval-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
_shouldAutoProcess(riskAssessment, context) {
// Auto-approve low risk items in dev/test environments
return (
riskAssessment.level === 'low' &&
(context.environment === 'development' || context.environment === 'test') &&
this.config.allowAutoApproval
);
}
_processAutoApproval(approvalRequest) {
console.log(`š¤ Auto-approving low-risk request: ${approvalRequest.id}`);
approvalRequest.status = 'approved';
approvalRequest.completedAt = Date.now();
approvalRequest.approvers.push({
approver: 'system',
decision: true,
reason: 'Auto-approved: low risk in development environment',
timestamp: Date.now()
});
this.stats.approvedRequests++;
this.approvalHistory.push(approvalRequest);
return {
success: true,
autoApproved: true,
approvalId: approvalRequest.id,
artifact: approvalRequest.artifact,
approvalCount: 1,
deniedCount: 0,
pendingCount: 0
};
}
_validateDecision(decision, approval) {
if (!decision.approver) {
throw new Error('Approver identity required');
}
if (typeof decision.approved !== 'boolean') {
throw new Error('Decision must be boolean (approved: true/false)');
}
if (!decision.reason) {
throw new Error('Approval reason required');
}
return decision;
}
_applyApprovedModifications(approval) {
let artifact = approval.artifact;
// Apply any modifications from approvers
approval.approvers.forEach(approver => {
if (approver.modifications && approver.modifications.length > 0) {
artifact = this._applyModifications(artifact, approver.modifications);
}
});
return artifact;
}
_applyModifications(artifact, modifications) {
// Apply code modifications approved by reviewers
return artifact; // Placeholder - would apply actual modifications
}
_collectModifications(approval) {
return approval.approvers
.filter(a => a.modifications && a.modifications.length > 0)
.flatMap(a => a.modifications);
}
_generateRiskRecommendations(score, factors) {
const recommendations = [];
if (score >= 0.8) {
recommendations.push('Consider breaking this change into smaller, less risky parts');
recommendations.push('Require additional code review from security team');
}
if (factors.includes('hardcoded_secrets')) {
recommendations.push('Move all secrets to environment variables');
}
if (factors.includes('sql_injection_risk')) {
recommendations.push('Use parameterized queries or ORM methods');
}
if (factors.includes('api_breaking_changes')) {
recommendations.push('Version the API and maintain backward compatibility');
}
return recommendations;
}
_suggestMitigations(violations, factors) {
const mitigations = [];
factors.forEach(factor => {
switch (factor) {
case 'hardcoded_secrets':
mitigations.push('Use environment variables and secret management');
break;
case 'sql_injection_risk':
mitigations.push('Implement prepared statements');
break;
case 'missing_authentication':
mitigations.push('Add authentication middleware');
break;
case 'csrf_vulnerability':
mitigations.push('Implement CSRF tokens');
break;
default:
mitigations.push(`Address ${factor}`);
}
});
return [...new Set(mitigations)]; // Remove duplicates
}
_generateCodePreview(artifact, violations) {
const code = typeof artifact === 'string' ? artifact : artifact.content || '';
const lines = code.split('\n');
// Show context around violations
const previews = violations.slice(0, 3).map(violation => {
if (violation.line) {
const start = Math.max(0, violation.line - 3);
const end = Math.min(lines.length, violation.line + 3);
return {
rule: violation.rule.name,
lines: lines.slice(start, end),
violationLine: violation.line - start - 1
};
}
return null;
}).filter(Boolean);
return previews;
}
_generateApprovalActions(riskAssessment) {
const actions = [
{ id: 'approve', label: 'Approve', variant: 'primary' },
{ id: 'deny', label: 'Deny', variant: 'danger' }
];
if (riskAssessment.level === 'medium' || riskAssessment.level === 'low') {
actions.push({ id: 'approve_with_changes', label: 'Approve with Changes', variant: 'warning' });
}
return actions;
}
_updateAverageApprovalTime(newTime) {
const totalApprovals = this.stats.approvedRequests + this.stats.deniedRequests;
if (totalApprovals === 1) {
this.stats.averageApprovalTime = newTime;
} else {
const currentTotal = this.stats.averageApprovalTime * (totalApprovals - 1);
this.stats.averageApprovalTime = (currentTotal + newTime) / totalApprovals;
}
}
/**
* Get approval system statistics
*/
getStatistics() {
return {
...this.stats,
pendingApprovals: this.pendingApprovals.size,
totalHistory: this.approvalHistory.length,
config: {
approvalTimeout: this.config.approvalTimeout,
minimumApprovers: this.config.minimumApprovers,
riskThresholds: this.config.riskThresholds
}
};
}
/**
* Get all pending approvals
*/
getPendingApprovals() {
return Array.from(this.pendingApprovals.values()).map(approval => ({
id: approval.id,
status: approval.status,
riskLevel: approval.riskAssessment.level,
violationCount: approval.violations.length,
createdAt: approval.createdAt,
expiresAt: approval.expiresAt,
timeRemaining: Math.max(0, approval.expiresAt - Date.now()),
approvers: approval.approvers.length
}));
}
}
module.exports = { TierThreeApproval };