diff --git a/src/ai/claude-executor.ts b/src/ai/claude-executor.ts index 1b62da0..28b038f 100644 --- a/src/ai/claude-executor.ts +++ b/src/ai/claude-executor.ts @@ -7,7 +7,6 @@ // Production Claude agent execution with retry, git checkpoints, and audit logging import { fs, path } from 'zx'; -import chalk, { type ChalkInstance } from 'chalk'; import { query } from '@anthropic-ai/claude-agent-sdk'; import { isRetryableError, PentestError } from '../error-handling.js'; @@ -25,6 +24,7 @@ import { detectExecutionContext, formatErrorOutput, formatCompletionMessage } fr import { createProgressManager } from './progress-manager.js'; import { createAuditLogger } from './audit-logger.js'; import { getActualModelName } from './router-utils.js'; +import type { ActivityLogger } from '../temporal/activity-logger.js'; declare global { var SHANNON_DISABLE_LOADER: boolean | undefined; @@ -57,7 +57,8 @@ type McpServer = ReturnType | StdioMcpServer; // Configures MCP servers for agent execution, with Docker-specific Chromium handling function buildMcpServers( sourceDir: string, - agentName: string | null + agentName: string | null, + logger: ActivityLogger ): Record { const shannonHelperServer = createShannonHelperServer(sourceDir); @@ -70,7 +71,7 @@ function buildMcpServers( const playwrightMcpName = MCP_AGENT_MAPPING[promptTemplate as keyof typeof MCP_AGENT_MAPPING] || null; if (playwrightMcpName) { - console.log(chalk.gray(` Assigned ${agentName} -> ${playwrightMcpName}`)); + logger.info(`Assigned ${agentName} -> ${playwrightMcpName}`); const userDataDir = `/tmp/${playwrightMcpName}`; @@ -141,23 +142,23 @@ async function writeErrorLog( }; const logPath = path.join(sourceDir, 'error.log'); await fs.appendFile(logPath, JSON.stringify(errorLog) + '\n'); - } catch (logError) { - const logErrMsg = logError instanceof Error ? logError.message : String(logError); - console.log(chalk.gray(` (Failed to write error log: ${logErrMsg})`)); + } catch { + // Best-effort error log writing - don't propagate failures } } export async function validateAgentOutput( result: ClaudePromptResult, agentName: string | null, - sourceDir: string + sourceDir: string, + logger: ActivityLogger ): Promise { - console.log(chalk.blue(` Validating ${agentName} agent output`)); + logger.info(`Validating ${agentName} agent output`); try { // Check if agent completed successfully if (!result.success || !result.result) { - console.log(chalk.red(` Validation failed: Agent execution was unsuccessful`)); + logger.error('Validation failed: Agent execution was unsuccessful'); return false; } @@ -165,28 +166,27 @@ export async function validateAgentOutput( const validator = agentName ? AGENT_VALIDATORS[agentName as keyof typeof AGENT_VALIDATORS] : undefined; if (!validator) { - console.log(chalk.yellow(` No validator found for agent "${agentName}" - assuming success`)); - console.log(chalk.green(` Validation passed: Unknown agent with successful result`)); + logger.warn(`No validator found for agent "${agentName}" - assuming success`); + logger.info('Validation passed: Unknown agent with successful result'); return true; } - console.log(chalk.blue(` Using validator for agent: ${agentName}`)); - console.log(chalk.blue(` Source directory: ${sourceDir}`)); + logger.info(`Using validator for agent: ${agentName}`, { sourceDir }); // Apply validation function - const validationResult = await validator(sourceDir); + const validationResult = await validator(sourceDir, logger); if (validationResult) { - console.log(chalk.green(` Validation passed: Required files/structure present`)); + logger.info('Validation passed: Required files/structure present'); } else { - console.log(chalk.red(` Validation failed: Missing required deliverable files`)); + logger.error('Validation failed: Missing required deliverable files'); } return validationResult; } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - console.log(chalk.red(` Validation failed with error: ${errMsg}`)); + logger.error(`Validation failed with error: ${errMsg}`); return false; } } @@ -199,8 +199,8 @@ export async function runClaudePrompt( context: string = '', description: string = 'Claude analysis', agentName: string | null = null, - colorFn: ChalkInstance = chalk.cyan, - auditSession: AuditSession | null = null + auditSession: AuditSession | null = null, + logger: ActivityLogger ): Promise { const timer = new Timer(`agent-${description.toLowerCase().replace(/\s+/g, '-')}`); const fullPrompt = context ? `${context}\n\n${prompt}` : prompt; @@ -212,9 +212,9 @@ export async function runClaudePrompt( ); const auditLogger = createAuditLogger(auditSession); - console.log(chalk.blue(` Running Claude Code: ${description}...`)); + logger.info(`Running Claude Code: ${description}...`); - const mcpServers = buildMcpServers(sourceDir, agentName); + const mcpServers = buildMcpServers(sourceDir, agentName, logger); // Build env vars to pass to SDK subprocesses const sdkEnv: Record = { @@ -238,7 +238,7 @@ export async function runClaudePrompt( }; if (!execContext.useCleanOutput) { - console.log(chalk.gray(` SDK Options: maxTurns=${options.maxTurns}, cwd=${sourceDir}, permissions=BYPASS`)); + logger.info(`SDK Options: maxTurns=${options.maxTurns}, cwd=${sourceDir}, permissions=BYPASS`); } let turnCount = 0; @@ -252,7 +252,7 @@ export async function runClaudePrompt( const messageLoopResult = await processMessageStream( fullPrompt, options, - { execContext, description, colorFn, progress, auditLogger }, + { execContext, description, progress, auditLogger, logger }, timer ); @@ -277,7 +277,7 @@ export async function runClaudePrompt( timingResults.agents[execContext.agentKey] = duration; if (apiErrorDetected) { - console.log(chalk.yellow(` API Error detected in ${description} - will validate deliverables before failing`)); + logger.warn(`API Error detected in ${description} - will validate deliverables before failing`); } progress.finish(formatCompletionMessage(execContext, description, turnCount, duration)); @@ -328,9 +328,9 @@ interface MessageLoopResult { interface MessageLoopDeps { execContext: ReturnType; description: string; - colorFn: ChalkInstance; progress: ReturnType; auditLogger: ReturnType; + logger: ActivityLogger; } async function processMessageStream( @@ -339,7 +339,7 @@ async function processMessageStream( deps: MessageLoopDeps, timer: Timer ): Promise { - const { execContext, description, colorFn, progress, auditLogger } = deps; + const { execContext, description, progress, auditLogger, logger } = deps; const HEARTBEAT_INTERVAL = 30000; let turnCount = 0; @@ -353,7 +353,7 @@ async function processMessageStream( // Heartbeat logging when loader is disabled const now = Date.now(); if (global.SHANNON_DISABLE_LOADER && now - lastHeartbeat > HEARTBEAT_INTERVAL) { - console.log(chalk.blue(` [${Math.floor((now - timer.startTime) / 1000)}s] ${description} running... (Turn ${turnCount})`)); + logger.info(`[${Math.floor((now - timer.startTime) / 1000)}s] ${description} running... (Turn ${turnCount})`); lastHeartbeat = now; } @@ -365,7 +365,7 @@ async function processMessageStream( const dispatchResult = await dispatchMessage( message as { type: string; subtype?: string }, turnCount, - { execContext, description, colorFn, progress, auditLogger } + { execContext, description, progress, auditLogger, logger } ); if (dispatchResult.type === 'throw') { diff --git a/src/ai/message-handlers.ts b/src/ai/message-handlers.ts index c791be8..cf5558f 100644 --- a/src/ai/message-handlers.ts +++ b/src/ai/message-handlers.ts @@ -11,8 +11,8 @@ import { ErrorCode } from '../types/errors.js'; import { matchesBillingTextPattern } from '../utils/billing-detection.js'; import { filterJsonToolCalls } from '../utils/output-formatter.js'; import { formatTimestamp } from '../utils/formatting.js'; -import chalk from 'chalk'; import { getActualModelName } from './router-utils.js'; +import type { ActivityLogger } from '../temporal/activity-logger.js'; import { formatAssistantOutput, formatResultOutput, @@ -37,7 +37,6 @@ import type { SystemInitMessage, ExecutionContext, } from './types.js'; -import type { ChalkInstance } from 'chalk'; // Handles both array and string content formats from SDK function extractMessageContent(message: AssistantMessage): string { @@ -232,7 +231,7 @@ function handleResultMessage(message: ResultMessage): ResultData { if (message.stop_reason !== undefined) { result.stop_reason = message.stop_reason; if (message.stop_reason && message.stop_reason !== 'end_turn') { - console.log(chalk.yellow(` Stop reason: ${message.stop_reason}`)); + console.log(` Stop reason: ${message.stop_reason}`); } } @@ -281,9 +280,9 @@ export type MessageDispatchAction = export interface MessageDispatchDeps { execContext: ExecutionContext; description: string; - colorFn: ChalkInstance; progress: ProgressManager; auditLogger: AuditLogger; + logger: ActivityLogger; } // Dispatches SDK messages to appropriate handlers and formatters @@ -292,7 +291,7 @@ export async function dispatchMessage( turnCount: number, deps: MessageDispatchDeps ): Promise { - const { execContext, description, colorFn, progress, auditLogger } = deps; + const { execContext, description, progress, auditLogger, logger } = deps; switch (message.type) { case 'assistant': { @@ -308,8 +307,7 @@ export async function dispatchMessage( assistantResult.cleanedContent, execContext, turnCount, - description, - colorFn + description )); progress.start(); } @@ -317,7 +315,7 @@ export async function dispatchMessage( await auditLogger.logLlmResponse(turnCount, assistantResult.content); if (assistantResult.apiErrorDetected) { - console.log(chalk.red(` API Error detected in assistant response`)); + logger.warn('API Error detected in assistant response'); return { type: 'continue', apiErrorDetected: true }; } @@ -329,10 +327,10 @@ export async function dispatchMessage( const initMsg = message as SystemInitMessage; const actualModel = getActualModelName(initMsg.model); if (!execContext.useCleanOutput) { - console.log(chalk.blue(` Model: ${actualModel}, Permission: ${initMsg.permissionMode}`)); + logger.info(`Model: ${actualModel}, Permission: ${initMsg.permissionMode}`); if (initMsg.mcp_servers && initMsg.mcp_servers.length > 0) { const mcpStatus = initMsg.mcp_servers.map(s => `${s.name}(${s.status})`).join(', '); - console.log(chalk.blue(` MCP: ${mcpStatus}`)); + logger.info(`MCP: ${mcpStatus}`); } } // Return actual model for tracking in audit logs @@ -370,7 +368,7 @@ export async function dispatchMessage( } default: - console.log(chalk.gray(` ${message.type}: ${JSON.stringify(message, null, 2)}`)); + logger.info(`Unhandled message type: ${message.type}`); return { type: 'continue' }; } } diff --git a/src/ai/output-formatters.ts b/src/ai/output-formatters.ts index 833c71c..35bf91a 100644 --- a/src/ai/output-formatters.ts +++ b/src/ai/output-formatters.ts @@ -6,7 +6,6 @@ // Pure functions for formatting console output -import chalk from 'chalk'; import { extractAgentType, formatDuration } from '../utils/formatting.js'; import { getAgentPrefix } from '../utils/output-formatter.js'; import type { ExecutionContext, ResultData } from './types.js'; @@ -33,8 +32,7 @@ export function formatAssistantOutput( cleanedContent: string, context: ExecutionContext, turnCount: number, - description: string, - colorFn: typeof chalk.cyan = chalk.cyan + description: string ): string[] { if (!cleanedContent.trim()) { return []; @@ -45,11 +43,11 @@ export function formatAssistantOutput( if (context.isParallelExecution) { // Compact output for parallel agents with prefixes const prefix = getAgentPrefix(description); - lines.push(colorFn(`${prefix} ${cleanedContent}`)); + lines.push(`${prefix} ${cleanedContent}`); } else { // Full turn output for sequential agents - lines.push(colorFn(`\n Turn ${turnCount} (${description}):`)); - lines.push(colorFn(` ${cleanedContent}`)); + lines.push(`\n Turn ${turnCount} (${description}):`); + lines.push(` ${cleanedContent}`); } return lines; @@ -58,28 +56,24 @@ export function formatAssistantOutput( export function formatResultOutput(data: ResultData, showFullResult: boolean): string[] { const lines: string[] = []; - lines.push(chalk.magenta(`\n COMPLETED:`)); - lines.push( - chalk.gray( - ` Duration: ${(data.duration_ms / 1000).toFixed(1)}s, Cost: $${data.cost.toFixed(4)}` - ) - ); + lines.push(`\n COMPLETED:`); + lines.push(` Duration: ${(data.duration_ms / 1000).toFixed(1)}s, Cost: $${data.cost.toFixed(4)}`); if (data.subtype === 'error_max_turns') { - lines.push(chalk.red(` Stopped: Hit maximum turns limit`)); + lines.push(` Stopped: Hit maximum turns limit`); } else if (data.subtype === 'error_during_execution') { - lines.push(chalk.red(` Stopped: Execution error`)); + lines.push(` Stopped: Execution error`); } if (data.permissionDenials > 0) { - lines.push(chalk.yellow(` ${data.permissionDenials} permission denials`)); + lines.push(` ${data.permissionDenials} permission denials`); } if (showFullResult && data.result && typeof data.result === 'string') { if (data.result.length > 1000) { - lines.push(chalk.magenta(` ${data.result.slice(0, 1000)}... [${data.result.length} total chars]`)); + lines.push(` ${data.result.slice(0, 1000)}... [${data.result.length} total chars]`); } else { - lines.push(chalk.magenta(` ${data.result}`)); + lines.push(` ${data.result}`); } } @@ -98,24 +92,24 @@ export function formatErrorOutput( if (context.isParallelExecution) { const prefix = getAgentPrefix(description); - lines.push(chalk.red(`${prefix} Failed (${formatDuration(duration)})`)); + lines.push(`${prefix} Failed (${formatDuration(duration)})`); } else if (context.useCleanOutput) { - lines.push(chalk.red(`${context.agentType} failed (${formatDuration(duration)})`)); + lines.push(`${context.agentType} failed (${formatDuration(duration)})`); } else { - lines.push(chalk.red(` Claude Code failed: ${description} (${formatDuration(duration)})`)); + lines.push(` Claude Code failed: ${description} (${formatDuration(duration)})`); } - lines.push(chalk.red(` Error Type: ${error.constructor.name}`)); - lines.push(chalk.red(` Message: ${error.message}`)); - lines.push(chalk.gray(` Agent: ${description}`)); - lines.push(chalk.gray(` Working Directory: ${sourceDir}`)); - lines.push(chalk.gray(` Retryable: ${isRetryable ? 'Yes' : 'No'}`)); + lines.push(` Error Type: ${error.constructor.name}`); + lines.push(` Message: ${error.message}`); + lines.push(` Agent: ${description}`); + lines.push(` Working Directory: ${sourceDir}`); + lines.push(` Retryable: ${isRetryable ? 'Yes' : 'No'}`); if (error.code) { - lines.push(chalk.gray(` Error Code: ${error.code}`)); + lines.push(` Error Code: ${error.code}`); } if (error.status) { - lines.push(chalk.gray(` HTTP Status: ${error.status}`)); + lines.push(` HTTP Status: ${error.status}`); } return lines; @@ -129,18 +123,14 @@ export function formatCompletionMessage( ): string { if (context.isParallelExecution) { const prefix = getAgentPrefix(description); - return chalk.green(`${prefix} Complete (${turnCount} turns, ${formatDuration(duration)})`); + return `${prefix} Complete (${turnCount} turns, ${formatDuration(duration)})`; } if (context.useCleanOutput) { - return chalk.green( - `${context.agentType.charAt(0).toUpperCase() + context.agentType.slice(1)} complete! (${turnCount} turns, ${formatDuration(duration)})` - ); + return `${context.agentType.charAt(0).toUpperCase() + context.agentType.slice(1)} complete! (${turnCount} turns, ${formatDuration(duration)})`; } - return chalk.green( - ` Claude Code completed: ${description} (${turnCount} turns) in ${formatDuration(duration)}` - ); + return ` Claude Code completed: ${description} (${turnCount} turns) in ${formatDuration(duration)}`; } export function formatToolUseOutput( @@ -149,9 +139,9 @@ export function formatToolUseOutput( ): string[] { const lines: string[] = []; - lines.push(chalk.yellow(`\n Using Tool: ${toolName}`)); + lines.push(`\n Using Tool: ${toolName}`); if (input && Object.keys(input).length > 0) { - lines.push(chalk.gray(` Input: ${JSON.stringify(input, null, 2)}`)); + lines.push(` Input: ${JSON.stringify(input, null, 2)}`); } return lines; @@ -160,9 +150,9 @@ export function formatToolUseOutput( export function formatToolResultOutput(displayContent: string): string[] { const lines: string[] = []; - lines.push(chalk.green(` Tool Result:`)); + lines.push(` Tool Result:`); if (displayContent) { - lines.push(chalk.gray(` ${displayContent}`)); + lines.push(` ${displayContent}`); } return lines; diff --git a/src/constants.ts b/src/constants.ts index e69af39..9758684 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,19 +5,19 @@ // as published by the Free Software Foundation. import { path, fs } from 'zx'; -import chalk from 'chalk'; import { validateQueueAndDeliverable, type VulnType } from './queue-validation.js'; import type { AgentName, PlaywrightAgent, AgentValidator } from './types/agents.js'; +import type { ActivityLogger } from './temporal/activity-logger.js'; // Factory function for vulnerability queue validators function createVulnValidator(vulnType: VulnType): AgentValidator { - return async (sourceDir: string): Promise => { + return async (sourceDir: string, logger: ActivityLogger): Promise => { try { await validateQueueAndDeliverable(vulnType, sourceDir); return true; } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - console.log(chalk.yellow(` Queue validation failed for ${vulnType}: ${errMsg}`)); + logger.warn(`Queue validation failed for ${vulnType}: ${errMsg}`); return false; } }; @@ -91,7 +91,7 @@ export const AGENT_VALIDATORS: Record = Object.freeze 'authz-exploit': createExploitValidator('authz'), // Executive report agent - report: async (sourceDir: string): Promise => { + report: async (sourceDir: string, logger: ActivityLogger): Promise => { const reportFile = path.join( sourceDir, 'deliverables', @@ -101,9 +101,7 @@ export const AGENT_VALIDATORS: Record = Object.freeze const reportExists = await fs.pathExists(reportFile); if (!reportExists) { - console.log( - chalk.red(` ❌ Missing required deliverable: comprehensive_security_assessment_report.md`) - ); + logger.error('Missing required deliverable: comprehensive_security_assessment_report.md'); } return reportExists; diff --git a/src/phases/reporting.ts b/src/phases/reporting.ts index 128b91a..e8887ad 100644 --- a/src/phases/reporting.ts +++ b/src/phases/reporting.ts @@ -5,9 +5,9 @@ // as published by the Free Software Foundation. import { fs, path } from 'zx'; -import chalk from 'chalk'; import { PentestError } from '../error-handling.js'; import { ErrorCode } from '../types/errors.js'; +import type { ActivityLogger } from '../temporal/activity-logger.js'; interface DeliverableFile { name: string; @@ -16,7 +16,7 @@ interface DeliverableFile { } // Pure function: Assemble final report from specialist deliverables -export async function assembleFinalReport(sourceDir: string): Promise { +export async function assembleFinalReport(sourceDir: string, logger: ActivityLogger): Promise { const deliverableFiles: DeliverableFile[] = [ { name: 'Injection', path: 'injection_exploitation_evidence.md', required: false }, { name: 'XSS', path: 'xss_exploitation_evidence.md', required: false }, @@ -33,7 +33,7 @@ export async function assembleFinalReport(sourceDir: string): Promise { if (await fs.pathExists(filePath)) { const content = await fs.readFile(filePath, 'utf8'); sections.push(content); - console.log(chalk.green(`✅ Added ${file.name} findings`)); + logger.info(`Added ${file.name} findings`); } else if (file.required) { throw new PentestError( `Required deliverable file not found: ${file.path}`, @@ -43,14 +43,14 @@ export async function assembleFinalReport(sourceDir: string): Promise { ErrorCode.DELIVERABLE_NOT_FOUND ); } else { - console.log(chalk.gray(`⏭️ No ${file.name} deliverable found`)); + logger.info(`No ${file.name} deliverable found`); } } catch (error) { if (file.required) { throw error; } const err = error as Error; - console.log(chalk.yellow(`⚠️ Could not read ${file.path}: ${err.message}`)); + logger.warn(`Could not read ${file.path}: ${err.message}`); } } @@ -62,7 +62,7 @@ export async function assembleFinalReport(sourceDir: string): Promise { // Ensure deliverables directory exists await fs.ensureDir(deliverablesDir); await fs.writeFile(finalReportPath, finalContent); - console.log(chalk.green(`✅ Final report assembled at ${finalReportPath}`)); + logger.info(`Final report assembled at ${finalReportPath}`); } catch (error) { const err = error as Error; throw new PentestError( @@ -83,13 +83,14 @@ export async function assembleFinalReport(sourceDir: string): Promise { */ export async function injectModelIntoReport( repoPath: string, - outputPath: string + outputPath: string, + logger: ActivityLogger ): Promise { // 1. Read session.json to get model information const sessionJsonPath = path.join(outputPath, 'session.json'); if (!(await fs.pathExists(sessionJsonPath))) { - console.log(chalk.yellow('⚠️ session.json not found, skipping model injection')); + logger.warn('session.json not found, skipping model injection'); return; } @@ -110,18 +111,18 @@ export async function injectModelIntoReport( } if (models.size === 0) { - console.log(chalk.yellow('⚠️ No model information found in session.json')); + logger.warn('No model information found in session.json'); return; } const modelStr = Array.from(models).join(', '); - console.log(chalk.blue(`📝 Injecting model info into report: ${modelStr}`)); + logger.info(`Injecting model info into report: ${modelStr}`); // 3. Read the final report const reportPath = path.join(repoPath, 'deliverables', 'comprehensive_security_assessment_report.md'); if (!(await fs.pathExists(reportPath))) { - console.log(chalk.yellow('⚠️ Final report not found, skipping model injection')); + logger.warn('Final report not found, skipping model injection'); return; } @@ -139,7 +140,7 @@ export async function injectModelIntoReport( assessmentDatePattern, `$1\n${modelLine}` ); - console.log(chalk.green('✅ Model info injected into Executive Summary')); + logger.info('Model info injected into Executive Summary'); } else { // If no Assessment Date line found, try to add after Executive Summary header const execSummaryPattern = /^## Executive Summary$/m; @@ -149,9 +150,9 @@ export async function injectModelIntoReport( execSummaryPattern, `## Executive Summary\n- Model: ${modelStr}` ); - console.log(chalk.green('✅ Model info added to Executive Summary header')); + logger.info('Model info added to Executive Summary header'); } else { - console.log(chalk.yellow('⚠️ Could not find Executive Summary section')); + logger.warn('Could not find Executive Summary section'); return; } } diff --git a/src/progress-indicator.ts b/src/progress-indicator.ts index d6700d4..4ecaa0a 100644 --- a/src/progress-indicator.ts +++ b/src/progress-indicator.ts @@ -4,8 +4,6 @@ // it under the terms of the GNU Affero General Public License version 3 // as published by the Free Software Foundation. -import chalk from 'chalk'; - export class ProgressIndicator { private message: string; private frames: string[] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -25,9 +23,7 @@ export class ProgressIndicator { this.interval = setInterval(() => { // Clear the line and write the spinner - process.stdout.write( - `\r${chalk.cyan(this.frames[this.frameIndex])} ${chalk.dim(this.message)}` - ); + process.stdout.write(`\r${this.frames[this.frameIndex]} ${this.message}`); this.frameIndex = (this.frameIndex + 1) % this.frames.length; }, 100); } @@ -47,6 +43,6 @@ export class ProgressIndicator { finish(successMessage: string = 'Complete'): void { this.stop(); - console.log(chalk.green(`✓ ${successMessage}`)); + console.log(`✓ ${successMessage}`); } } diff --git a/src/prompts/prompt-manager.ts b/src/prompts/prompt-manager.ts index 3ff47d5..0af37c4 100644 --- a/src/prompts/prompt-manager.ts +++ b/src/prompts/prompt-manager.ts @@ -5,10 +5,10 @@ // as published by the Free Software Foundation. import { fs, path } from 'zx'; -import chalk from 'chalk'; import { PentestError, handlePromptError } from '../error-handling.js'; import { MCP_AGENT_MAPPING } from '../constants.js'; import type { Authentication, DistributedConfig } from '../types/config.js'; +import type { ActivityLogger } from '../temporal/activity-logger.js'; interface PromptVariables { webUrl: string; @@ -22,7 +22,7 @@ interface IncludeReplacement { } // Pure function: Build complete login instructions from config -async function buildLoginInstructions(authentication: Authentication): Promise { +async function buildLoginInstructions(authentication: Authentication, logger: ActivityLogger): Promise { try { // Load the login instructions template const loginInstructionsPath = path.join(import.meta.dirname, '..', '..', 'prompts', 'shared', 'login-instructions.txt'); @@ -56,7 +56,7 @@ async function buildLoginInstructions(authentication: Authentication): Promise { try { if (!template || typeof template !== 'string') { @@ -174,7 +175,7 @@ async function interpolateVariables( // Extract and inject login instructions from config if (config.authentication?.login_flow) { - const loginInstructions = await buildLoginInstructions(config.authentication); + const loginInstructions = await buildLoginInstructions(config.authentication, logger); result = result.replace(/{{LOGIN_INSTRUCTIONS}}/g, loginInstructions); } else { result = result.replace(/{{LOGIN_INSTRUCTIONS}}/g, ''); @@ -189,7 +190,7 @@ async function interpolateVariables( // Validate that all placeholders have been replaced (excluding instructional text) const remainingPlaceholders = result.match(/\{\{[^}]+\}\}/g); if (remainingPlaceholders) { - console.log(chalk.yellow(`⚠️ Warning: Found unresolved placeholders in prompt: ${remainingPlaceholders.join(', ')}`)); + logger.warn(`Found unresolved placeholders in prompt: ${remainingPlaceholders.join(', ')}`); } return result; @@ -212,7 +213,8 @@ export async function loadPrompt( promptName: string, variables: PromptVariables, config: DistributedConfig | null = null, - pipelineTestingMode: boolean = false + pipelineTestingMode: boolean = false, + logger: ActivityLogger ): Promise { try { // Use pipeline testing prompts if pipeline testing mode is enabled @@ -222,7 +224,7 @@ export async function loadPrompt( // Debug message for pipeline testing mode if (pipelineTestingMode) { - console.log(chalk.yellow(`⚡ Using pipeline testing prompt: ${promptPath}`)); + logger.info(`Using pipeline testing prompt: ${promptPath}`); } // Check if file exists first @@ -242,11 +244,11 @@ export async function loadPrompt( const mcpServer = MCP_AGENT_MAPPING[promptName as keyof typeof MCP_AGENT_MAPPING]; if (mcpServer) { enhancedVariables.MCP_SERVER = mcpServer; - console.log(chalk.gray(` 🎭 Assigned ${promptName} → ${enhancedVariables.MCP_SERVER}`)); + logger.info(`Assigned ${promptName} -> ${enhancedVariables.MCP_SERVER}`); } else { // Fallback for unknown agents enhancedVariables.MCP_SERVER = 'playwright-agent1'; - console.log(chalk.yellow(` 🎭 Unknown agent ${promptName}, using fallback → ${enhancedVariables.MCP_SERVER}`)); + logger.warn(`Unknown agent ${promptName}, using fallback -> ${enhancedVariables.MCP_SERVER}`); } let template = await fs.readFile(promptPath, 'utf8'); @@ -254,7 +256,7 @@ export async function loadPrompt( // Pre-process the template to handle @include directives template = await processIncludes(template, promptsDir); - return await interpolateVariables(template, enhancedVariables, config); + return await interpolateVariables(template, enhancedVariables, config, logger); } catch (error) { if (error instanceof PentestError) { throw error; diff --git a/src/services/agent-execution.ts b/src/services/agent-execution.ts index ee09d5c..771e7b7 100644 --- a/src/services/agent-execution.ts +++ b/src/services/agent-execution.ts @@ -21,8 +21,7 @@ * No Temporal dependencies - pure domain logic. */ -import chalk from 'chalk'; - +import type { ActivityLogger } from '../temporal/activity-logger.js'; import { Result, ok, err, isErr } from '../types/result.js'; import { ErrorCode } from '../types/errors.js'; import { PentestError } from '../error-handling.js'; @@ -83,7 +82,8 @@ export class AgentExecutionService { async execute( agentName: AgentName, input: AgentExecutionInput, - auditSession: AuditSession + auditSession: AuditSession, + logger: ActivityLogger ): Promise> { const { webUrl, repoPath, configPath, pipelineTestingMode = false, attemptNumber } = input; @@ -102,7 +102,8 @@ export class AgentExecutionService { promptTemplate, { webUrl, repoPath }, distributedConfig, - pipelineTestingMode + pipelineTestingMode, + logger ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -119,7 +120,7 @@ export class AgentExecutionService { // 3. Create git checkpoint before execution try { - await createGitCheckpoint(repoPath, agentName, attemptNumber); + await createGitCheckpoint(repoPath, agentName, attemptNumber, logger); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return err( @@ -143,15 +144,15 @@ export class AgentExecutionService { '', // context agentName, // description agentName, - chalk.cyan, - auditSession + auditSession, + logger ); // 6. Spending cap check - defense-in-depth if (result.success && (result.turns ?? 0) <= 2 && (result.cost || 0) === 0) { const resultText = result.result || ''; if (isSpendingCapBehavior(result.turns ?? 0, result.cost || 0, resultText)) { - await rollbackGitWorkspace(repoPath, 'spending cap detected'); + await rollbackGitWorkspace(repoPath, 'spending cap detected', logger); const endResult: AgentEndResult = { attemptNumber, duration_ms: result.duration, @@ -175,7 +176,7 @@ export class AgentExecutionService { // 7. Handle execution failure if (!result.success) { - await rollbackGitWorkspace(repoPath, 'execution failure'); + await rollbackGitWorkspace(repoPath, 'execution failure', logger); const endResult: AgentEndResult = { attemptNumber, duration_ms: result.duration, @@ -197,9 +198,9 @@ export class AgentExecutionService { } // 8. Validate output - const validationPassed = await validateAgentOutput(result, agentName, repoPath); + const validationPassed = await validateAgentOutput(result, agentName, repoPath, logger); if (!validationPassed) { - await rollbackGitWorkspace(repoPath, 'validation failure'); + await rollbackGitWorkspace(repoPath, 'validation failure', logger); const endResult: AgentEndResult = { attemptNumber, duration_ms: result.duration, @@ -221,7 +222,7 @@ export class AgentExecutionService { } // 9. Success - commit deliverables, then capture checkpoint hash - await commitGitSuccess(repoPath, agentName); + await commitGitSuccess(repoPath, agentName, logger); const commitHash = await getGitCommitHash(repoPath); const endResult: AgentEndResult = { @@ -253,9 +254,10 @@ export class AgentExecutionService { async executeOrThrow( agentName: AgentName, input: AgentExecutionInput, - auditSession: AuditSession + auditSession: AuditSession, + logger: ActivityLogger ): Promise { - const result = await this.execute(agentName, input, auditSession); + const result = await this.execute(agentName, input, auditSession, logger); if (isErr(result)) { throw result.error; } diff --git a/src/services/exploitation-checker.ts b/src/services/exploitation-checker.ts index a5c30d7..326188c 100644 --- a/src/services/exploitation-checker.ts +++ b/src/services/exploitation-checker.ts @@ -13,13 +13,13 @@ * No Temporal dependencies - this is pure business logic. */ -import chalk from 'chalk'; import { validateQueueSafe, type VulnType, type ExploitationDecision, } from '../queue-validation.js'; import { isOk } from '../types/result.js'; +import type { ActivityLogger } from '../temporal/activity-logger.js'; /** * Service for checking exploitation queue decisions. @@ -36,18 +36,17 @@ export class ExploitationCheckerService { * * @param vulnType - Type of vulnerability (injection, xss, auth, ssrf, authz) * @param repoPath - Path to the repository containing deliverables + * @param logger - ActivityLogger for structured logging * @returns ExploitationDecision indicating whether to exploit * @throws PentestError if validation fails and is retryable */ - async checkQueue(vulnType: VulnType, repoPath: string): Promise { + async checkQueue(vulnType: VulnType, repoPath: string, logger: ActivityLogger): Promise { const result = await validateQueueSafe(vulnType, repoPath); if (isOk(result)) { const decision = result.value; - console.log( - chalk.blue( - ` ${vulnType}: ${decision.shouldExploit ? `${decision.vulnerabilityCount} vulnerabilities found` : 'no vulnerabilities, skipping exploitation'}` - ) + logger.info( + `${vulnType}: ${decision.shouldExploit ? `${decision.vulnerabilityCount} vulnerabilities found` : 'no vulnerabilities, skipping exploitation'}` ); return decision; } @@ -56,14 +55,12 @@ export class ExploitationCheckerService { const error = result.error; if (error.retryable) { // Re-throw retryable errors so caller can handle retry - console.log(chalk.yellow(` ${vulnType}: ${error.message} (retryable)`)); + logger.warn(`${vulnType}: ${error.message} (retryable)`); throw error; } // Non-retryable error - skip exploitation gracefully - console.log( - chalk.yellow(` ${vulnType}: ${error.message}, skipping exploitation`) - ); + logger.warn(`${vulnType}: ${error.message}, skipping exploitation`); return { shouldExploit: false, shouldRetry: false, diff --git a/src/temporal/activities.ts b/src/temporal/activities.ts index 88abcfd..e1138dc 100644 --- a/src/temporal/activities.ts +++ b/src/temporal/activities.ts @@ -16,7 +16,6 @@ */ import { heartbeat, ApplicationFailure, Context } from '@temporalio/activity'; -import chalk from 'chalk'; import path from 'path'; import fs from 'fs/promises'; @@ -35,6 +34,7 @@ import { assembleFinalReport, injectModelIntoReport } from '../phases/reporting. import { AGENTS } from '../session-manager.js'; import { executeGitCommandWithRetry } from '../utils/git-manager.js'; import type { ResumeAttempt } from '../audit/metrics-tracker.js'; +import { createActivityLogger } from './activity-logger.js'; // Max lengths to prevent Temporal protobuf buffer overflow const MAX_ERROR_MESSAGE_LENGTH = 2000; @@ -114,6 +114,8 @@ async function runAgentActivity( }, HEARTBEAT_INTERVAL_MS); try { + const logger = createActivityLogger(); + // Build session metadata and get/create container const sessionMetadata = buildSessionMetadata(input); const container = getOrCreateContainer(workflowId, sessionMetadata); @@ -134,7 +136,8 @@ async function runAgentActivity( pipelineTestingMode, attemptNumber, }, - auditSession + auditSession, + logger ); // Success - return metrics @@ -251,12 +254,13 @@ export async function runReportAgent(input: ActivityInput): Promise { const { repoPath } = input; - console.log(chalk.blue(' Assembling deliverables from specialist agents...')); + const logger = createActivityLogger(); + logger.info('Assembling deliverables from specialist agents...'); try { - await assembleFinalReport(repoPath); + await assembleFinalReport(repoPath, logger); } catch (error) { const err = error as Error; - console.log(chalk.yellow(` Warning: Error assembling final report: ${err.message}`)); + logger.warn(`Error assembling final report: ${err.message}`); } } @@ -265,14 +269,15 @@ export async function assembleReportActivity(input: ActivityInput): Promise { const { repoPath, sessionId, outputPath } = input; + const logger = createActivityLogger(); const effectiveOutputPath = outputPath ? path.join(outputPath, sessionId) : path.join('./audit-logs', sessionId); try { - await injectModelIntoReport(repoPath, effectiveOutputPath); + await injectModelIntoReport(repoPath, effectiveOutputPath, logger); } catch (error) { const err = error as Error; - console.log(chalk.yellow(` Warning: Error injecting model into report: ${err.message}`)); + logger.warn(`Error injecting model into report: ${err.message}`); } } @@ -289,12 +294,13 @@ export async function checkExploitationQueue( vulnType: VulnType ): Promise { const { repoPath, workflowId } = input; + const logger = createActivityLogger(); // Reuse container's service if available (from prior vuln agent runs) const existingContainer = getContainer(workflowId); const checker = existingContainer?.exploitationChecker ?? new ExploitationCheckerService(); - return checker.checkQueue(vulnType, repoPath); + return checker.checkQueue(vulnType, repoPath, logger); } // === Resume Activities === @@ -368,9 +374,8 @@ export async function loadResumeState( const deliverableExists = await fileExists(deliverablePath); if (!deliverableExists) { - console.log( - chalk.yellow(`Agent ${agentName} shows success but deliverable missing, will re-run`) - ); + const logger = createActivityLogger(); + logger.warn(`Agent ${agentName} shows success but deliverable missing, will re-run`); continue; } @@ -400,10 +405,12 @@ export async function loadResumeState( const checkpointHash = await findLatestCommit(expectedRepoPath, checkpoints); const originalWorkflowId = session.session.originalWorkflowId || session.session.id; - console.log(chalk.cyan(`=== RESUME STATE ===`)); - console.log(`Workspace: ${workspaceName}`); - console.log(`Completed agents: ${completedAgents.length}`); - console.log(`Checkpoint: ${checkpointHash}`); + const logger = createActivityLogger(); + logger.info('Resume state loaded', { + workspace: workspaceName, + completedAgents: completedAgents.length, + checkpoint: checkpointHash, + }); return { workspaceName, @@ -446,7 +453,8 @@ export async function restoreGitCheckpoint( checkpointHash: string, incompleteAgents: AgentName[] ): Promise { - console.log(chalk.blue(`Restoring git workspace to ${checkpointHash}...`)); + const logger = createActivityLogger(); + logger.info(`Restoring git workspace to ${checkpointHash}...`); await executeGitCommandWithRetry( ['git', 'reset', '--hard', checkpointHash], @@ -465,15 +473,15 @@ export async function restoreGitCheckpoint( try { const exists = await fileExists(deliverablePath); if (exists) { - console.log(chalk.yellow(`Cleaning partial deliverable: ${agentName}`)); + logger.warn(`Cleaning partial deliverable: ${agentName}`); await fs.unlink(deliverablePath); } } catch (error) { - console.log(chalk.gray(`Note: Failed to delete ${deliverablePath}: ${error}`)); + logger.info(`Note: Failed to delete ${deliverablePath}: ${error}`); } } - console.log(chalk.green('Workspace restored to clean state')); + logger.info('Workspace restored to clean state'); } /** @@ -561,7 +569,10 @@ export async function logWorkflowComplete( try { await copyDeliverablesToAudit(sessionMetadata, repoPath); } catch (copyErr) { - console.error('Failed to copy deliverables to audit-logs:', copyErr); + const logger = createActivityLogger(); + logger.error('Failed to copy deliverables to audit-logs', { + error: copyErr instanceof Error ? copyErr.message : String(copyErr), + }); } // Clean up container diff --git a/src/temporal/activity-logger.ts b/src/temporal/activity-logger.ts new file mode 100644 index 0000000..87cd1ad --- /dev/null +++ b/src/temporal/activity-logger.ts @@ -0,0 +1,43 @@ +// Copyright (C) 2025 Keygraph, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License version 3 +// as published by the Free Software Foundation. + +import { Context } from '@temporalio/activity'; + +/** + * Logger interface for services called from Temporal activities. + * Keeps services Temporal-agnostic while providing structured logging. + */ +export interface ActivityLogger { + info(message: string, attrs?: Record): void; + warn(message: string, attrs?: Record): void; + error(message: string, attrs?: Record): void; +} + +/** + * ActivityLogger backed by Temporal's Context.current().log. + * Must be called inside a running Temporal activity — throws otherwise. + */ +export class TemporalActivityLogger implements ActivityLogger { + info(message: string, attrs?: Record): void { + Context.current().log.info(message, attrs ?? {}); + } + + warn(message: string, attrs?: Record): void { + Context.current().log.warn(message, attrs ?? {}); + } + + error(message: string, attrs?: Record): void { + Context.current().log.error(message, attrs ?? {}); + } +} + +/** + * Create an ActivityLogger. Must be called inside a Temporal activity. + * Throws if called outside an activity context. + */ +export function createActivityLogger(): ActivityLogger { + return new TemporalActivityLogger(); +} diff --git a/src/temporal/client.ts b/src/temporal/client.ts index 243197e..1e8df2a 100644 --- a/src/temporal/client.ts +++ b/src/temporal/client.ts @@ -28,7 +28,6 @@ import { Connection, Client, WorkflowNotFoundError } from '@temporalio/client'; import dotenv from 'dotenv'; -import chalk from 'chalk'; import { displaySplashScreen } from '../splash-screen.js'; import { sanitizeHostname } from '../audit/utils.js'; import { readJson, fileExists } from '../audit/utils.js'; @@ -89,18 +88,18 @@ async function terminateExistingWorkflows( const description = await handle.describe(); if (description.status.name === 'RUNNING') { - console.log(chalk.yellow(`Terminating running workflow: ${wfId}`)); + console.log(`Terminating running workflow: ${wfId}`); await handle.terminate('Superseded by resume workflow'); terminated.push(wfId); - console.log(chalk.green(`Terminated: ${wfId}`)); + console.log(`Terminated: ${wfId}`); } else { - console.log(chalk.gray(`Workflow already ${description.status.name}: ${wfId}`)); + console.log(`Workflow already ${description.status.name}: ${wfId}`); } } catch (error) { if (error instanceof WorkflowNotFoundError) { - console.log(chalk.gray(`Workflow not found (already cleaned up): ${wfId}`)); + console.log(`Workflow not found (already cleaned up): ${wfId}`); } else { - console.log(chalk.red(`Failed to terminate ${wfId}: ${error}`)); + console.log(`Failed to terminate ${wfId}: ${error}`); // Continue anyway - don't block resume on termination failure } } @@ -118,13 +117,13 @@ function isValidWorkspaceName(name: string): boolean { } function showUsage(): void { - console.log(chalk.cyan.bold('\nShannon Temporal Client')); - console.log(chalk.gray('Start a pentest pipeline workflow\n')); - console.log(chalk.yellow('Usage:')); + console.log('\nShannon Temporal Client'); + console.log('Start a pentest pipeline workflow\n'); + console.log('Usage:'); console.log( ' node dist/temporal/client.js [options]\n' ); - console.log(chalk.yellow('Options:')); + console.log('Options:'); console.log(' --config Configuration file path'); console.log(' --output Output directory for audit logs'); console.log(' --pipeline-testing Use minimal prompts for fast testing'); @@ -133,7 +132,7 @@ function showUsage(): void { ' --workflow-id Custom workflow ID (default: shannon-)' ); console.log(' --wait Wait for workflow completion with progress polling\n'); - console.log(chalk.yellow('Examples:')); + console.log('Examples:'); console.log(' node dist/temporal/client.js https://example.com /path/to/repo'); console.log( ' node dist/temporal/client.js https://example.com /path/to/repo --config config.yaml\n' @@ -205,7 +204,7 @@ async function startPipeline(): Promise { } if (!webUrl || !repoPath) { - console.log(chalk.red('Error: webUrl and repoPath are required')); + console.log('Error: webUrl and repoPath are required'); showUsage(); process.exit(1); } @@ -214,7 +213,7 @@ async function startPipeline(): Promise { await displaySplashScreen(); const address = process.env.TEMPORAL_ADDRESS || 'localhost:7233'; - console.log(chalk.gray(`Connecting to Temporal at ${address}...`)); + console.log(`Connecting to Temporal at ${address}...`); const connection = await Connection.connect({ address }); const client = new Client({ connection }); @@ -232,21 +231,21 @@ async function startPipeline(): Promise { if (workspaceExists) { // === Resume Mode: existing workspace === isResume = true; - console.log(chalk.cyan('=== RESUME MODE ===')); + console.log('=== RESUME MODE ==='); console.log(`Workspace: ${resumeFromWorkspace}\n`); // Terminate any running workflows for this workspace terminatedWorkflows = await terminateExistingWorkflows(client, resumeFromWorkspace); if (terminatedWorkflows.length > 0) { - console.log(chalk.yellow(`Terminated ${terminatedWorkflows.length} previous workflow(s)\n`)); + console.log(`Terminated ${terminatedWorkflows.length} previous workflow(s)\n`); } // Validate URL matches workspace const session = await readJson(sessionPath); if (session.session.webUrl !== webUrl) { - console.error(chalk.red('ERROR: URL mismatch with workspace')); + console.error('ERROR: URL mismatch with workspace'); console.error(` Workspace URL: ${session.session.webUrl}`); console.error(` Provided URL: ${webUrl}`); process.exit(1); @@ -258,12 +257,12 @@ async function startPipeline(): Promise { } else { // === New Named Workspace === if (!isValidWorkspaceName(resumeFromWorkspace)) { - console.error(chalk.red(`ERROR: Invalid workspace name: "${resumeFromWorkspace}"`)); - console.error(chalk.gray(' Must be 1-128 characters, alphanumeric/hyphens/underscores, starting with alphanumeric')); + console.error(`ERROR: Invalid workspace name: "${resumeFromWorkspace}"`); + console.error(' Must be 1-128 characters, alphanumeric/hyphens/underscores, starting with alphanumeric'); process.exit(1); } - console.log(chalk.cyan('=== NEW NAMED WORKSPACE ===')); + console.log('=== NEW NAMED WORKSPACE ==='); console.log(`Workspace: ${resumeFromWorkspace}\n`); workflowId = `${resumeFromWorkspace}_shannon-${Date.now()}`; @@ -293,22 +292,22 @@ async function startPipeline(): Promise { const effectiveDisplayPath = displayOutputPath || outputPath || './audit-logs'; const outputDir = `${effectiveDisplayPath}/${sessionId}`; - console.log(chalk.green.bold(`✓ Workflow started: ${workflowId}`)); + console.log(`✓ Workflow started: ${workflowId}`); if (isResume) { - console.log(chalk.gray(` (Resuming workspace: ${sessionId})`)); + console.log(` (Resuming workspace: ${sessionId})`); } console.log(); - console.log(chalk.white(' Target: ') + chalk.cyan(webUrl)); - console.log(chalk.white(' Repository: ') + chalk.cyan(repoPath)); - console.log(chalk.white(' Workspace: ') + chalk.cyan(sessionId)); + console.log(` Target: ${webUrl}`); + console.log(` Repository: ${repoPath}`); + console.log(` Workspace: ${sessionId}`); if (configPath) { - console.log(chalk.white(' Config: ') + chalk.cyan(configPath)); + console.log(` Config: ${configPath}`); } if (displayOutputPath) { - console.log(chalk.white(' Output: ') + chalk.cyan(displayOutputPath)); + console.log(` Output: ${displayOutputPath}`); } if (pipelineTestingMode) { - console.log(chalk.white(' Mode: ') + chalk.yellow('Pipeline Testing')); + console.log(` Mode: Pipeline Testing`); } console.log(); @@ -323,12 +322,12 @@ async function startPipeline(): Promise { ); if (!waitForCompletion) { - console.log(chalk.bold('Monitor progress:')); - console.log(chalk.white(' Web UI: ') + chalk.blue(`http://localhost:8233/namespaces/default/workflows/${workflowId}`)); - console.log(chalk.white(' Logs: ') + chalk.gray(`./shannon logs ID=${workflowId}`)); + console.log('Monitor progress:'); + console.log(` Web UI: http://localhost:8233/namespaces/default/workflows/${workflowId}`); + console.log(` Logs: ./shannon logs ID=${workflowId}`); console.log(); - console.log(chalk.bold('Output:')); - console.log(chalk.white(' Reports: ') + chalk.cyan(outputDir)); + console.log('Output:'); + console.log(` Reports: ${outputDir}`); console.log(); return; } @@ -339,10 +338,7 @@ async function startPipeline(): Promise { const progress = await handle.query(PROGRESS_QUERY); const elapsed = Math.floor(progress.elapsedMs / 1000); console.log( - chalk.gray(`[${elapsed}s]`), - chalk.cyan(`Phase: ${progress.currentPhase || 'unknown'}`), - chalk.gray(`| Agent: ${progress.currentAgent || 'none'}`), - chalk.gray(`| Completed: ${progress.completedAgents.length}/13`) + `[${elapsed}s] Phase: ${progress.currentPhase || 'unknown'} | Agent: ${progress.currentAgent || 'none'} | Completed: ${progress.completedAgents.length}/13` ); } catch { // Workflow may have completed @@ -353,12 +349,12 @@ async function startPipeline(): Promise { const result = await handle.result(); clearInterval(progressInterval); - console.log(chalk.green.bold('\nPipeline completed successfully!')); + console.log('\nPipeline completed successfully!'); if (result.summary) { - console.log(chalk.gray(`Duration: ${Math.floor(result.summary.totalDurationMs / 1000)}s`)); - console.log(chalk.gray(`Agents completed: ${result.summary.agentCount}`)); - console.log(chalk.gray(`Total turns: ${result.summary.totalTurns}`)); - console.log(chalk.gray(`Run cost: $${result.summary.totalCostUsd.toFixed(4)}`)); + console.log(`Duration: ${Math.floor(result.summary.totalDurationMs / 1000)}s`); + console.log(`Agents completed: ${result.summary.agentCount}`); + console.log(`Total turns: ${result.summary.totalTurns}`); + console.log(`Run cost: $${result.summary.totalCostUsd.toFixed(4)}`); // Show cumulative cost from session.json (includes all resume attempts) if (isResume) { @@ -366,7 +362,7 @@ async function startPipeline(): Promise { const session = await readJson( path.join('./audit-logs', sessionId, 'session.json') ); - console.log(chalk.gray(`Cumulative cost: $${session.metrics.total_cost_usd.toFixed(4)}`)); + console.log(`Cumulative cost: $${session.metrics.total_cost_usd.toFixed(4)}`); } catch { // Non-fatal, skip cumulative cost display } @@ -374,7 +370,7 @@ async function startPipeline(): Promise { } } catch (error) { clearInterval(progressInterval); - console.error(chalk.red.bold('\nPipeline failed:'), error); + console.error('\nPipeline failed:', error); process.exit(1); } } finally { @@ -383,6 +379,6 @@ async function startPipeline(): Promise { } startPipeline().catch((err) => { - console.error(chalk.red('Client error:'), err); + console.error('Client error:', err); process.exit(1); }); diff --git a/src/temporal/worker.ts b/src/temporal/worker.ts index 81c7f7e..b0f2f9b 100644 --- a/src/temporal/worker.ts +++ b/src/temporal/worker.ts @@ -24,7 +24,6 @@ import { NativeConnection, Worker, bundleWorkflowCode } from '@temporalio/worker import { fileURLToPath } from 'node:url'; import path from 'node:path'; import dotenv from 'dotenv'; -import chalk from 'chalk'; import * as activities from './activities.js'; dotenv.config(); @@ -33,12 +32,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function runWorker(): Promise { const address = process.env.TEMPORAL_ADDRESS || 'localhost:7233'; - console.log(chalk.cyan(`Connecting to Temporal at ${address}...`)); + console.log(`Connecting to Temporal at ${address}...`); const connection = await NativeConnection.connect({ address }); // Bundle workflows for Temporal's V8 isolate - console.log(chalk.gray('Bundling workflows...')); + console.log('Bundling workflows...'); const workflowBundle = await bundleWorkflowCode({ workflowsPath: path.join(__dirname, 'workflows.js'), }); @@ -54,26 +53,26 @@ async function runWorker(): Promise { // Graceful shutdown handling const shutdown = async (): Promise => { - console.log(chalk.yellow('\nShutting down worker...')); + console.log('\nShutting down worker...'); worker.shutdown(); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); - console.log(chalk.green('Shannon worker started')); - console.log(chalk.gray('Task queue: shannon-pipeline')); - console.log(chalk.gray('Press Ctrl+C to stop\n')); + console.log('Shannon worker started'); + console.log('Task queue: shannon-pipeline'); + console.log('Press Ctrl+C to stop\n'); try { await worker.run(); } finally { await connection.close(); - console.log(chalk.gray('Worker stopped')); + console.log('Worker stopped'); } } runWorker().catch((err) => { - console.error(chalk.red('Worker failed:'), err); + console.error('Worker failed:', err); process.exit(1); }); diff --git a/src/temporal/workflows.ts b/src/temporal/workflows.ts index c94b274..a51bd0a 100644 --- a/src/temporal/workflows.ts +++ b/src/temporal/workflows.ts @@ -24,6 +24,7 @@ */ import { + log, proxyActivities, setHandler, workflowInfo, @@ -170,7 +171,7 @@ export async function pentestPipelineWorkflow( // Check if all agents are already complete if (resumeState.completedAgents.length === ALL_AGENTS.length) { - console.log(`All ${ALL_AGENTS.length} agents already completed. Nothing to resume.`); + log.info(`All ${ALL_AGENTS.length} agents already completed. Nothing to resume.`); state.status = 'completed'; state.completedAgents = [...resumeState.completedAgents]; state.summary = computeSummary(state); @@ -184,7 +185,7 @@ export async function pentestPipelineWorkflow( resumeState.checkpointHash ); - console.log('Resume state loaded and workspace restored'); + log.info('Resume state loaded and workspace restored'); } // Helper to check if an agent should be skipped @@ -203,7 +204,7 @@ export async function pentestPipelineWorkflow( state.completedAgents.push('pre-recon'); await a.logPhaseTransition(activityInput, 'pre-recon', 'complete'); } else { - console.log('Skipping pre-recon (already complete)'); + log.info('Skipping pre-recon (already complete)'); state.completedAgents.push('pre-recon'); } @@ -216,7 +217,7 @@ export async function pentestPipelineWorkflow( state.completedAgents.push('recon'); await a.logPhaseTransition(activityInput, 'recon', 'complete'); } else { - console.log('Skipping recon (already complete)'); + log.info('Skipping recon (already complete)'); state.completedAgents.push('recon'); } @@ -243,7 +244,7 @@ export async function pentestPipelineWorkflow( if (!shouldSkip(vulnAgentName)) { vulnMetrics = await runVulnAgent(); } else { - console.log(`Skipping ${vulnAgentName} (already complete)`); + log.info(`Skipping ${vulnAgentName} (already complete)`); } // Step 2: Check exploitation queue (only if vuln agent ran or completed previously) @@ -255,7 +256,7 @@ export async function pentestPipelineWorkflow( if (!shouldSkip(exploitAgentName)) { exploitMetrics = await runExploitAgent(); } else { - console.log(`Skipping ${exploitAgentName} (already complete)`); + log.info(`Skipping ${exploitAgentName} (already complete)`); } } @@ -329,7 +330,7 @@ export async function pentestPipelineWorkflow( runVulnExploitPipeline(config.vulnType, config.runVuln, config.runExploit) ); } else { - console.log( + log.info( `Skipping entire ${config.vulnType} pipeline (both agents complete)` ); // Still need to mark them as completed in state @@ -378,10 +379,9 @@ export async function pentestPipelineWorkflow( // Log any pipeline failures (workflow continues despite failures) if (failedPipelines.length > 0) { - console.log( - `⚠️ ${failedPipelines.length} pipeline(s) failed:`, - failedPipelines - ); + log.warn(`${failedPipelines.length} pipeline(s) failed`, { + failures: failedPipelines, + }); } // Update phase markers @@ -407,7 +407,7 @@ export async function pentestPipelineWorkflow( await a.logPhaseTransition(activityInput, 'reporting', 'complete'); } else { - console.log('Skipping report (already complete)'); + log.info('Skipping report (already complete)'); state.completedAgents.push('report'); } diff --git a/src/temporal/workspaces.ts b/src/temporal/workspaces.ts index 4f46cd0..62d6b29 100644 --- a/src/temporal/workspaces.ts +++ b/src/temporal/workspaces.ts @@ -20,7 +20,6 @@ import fs from 'fs/promises'; import path from 'path'; -import chalk from 'chalk'; interface SessionJson { session: { @@ -59,16 +58,7 @@ function formatDuration(ms: number): string { } function getStatusDisplay(status: string): string { - switch (status) { - case 'completed': - return chalk.green(status); - case 'in-progress': - return chalk.yellow(status); - case 'failed': - return chalk.red(status); - default: - return status; - } + return status; } function truncate(str: string, maxLen: number): string { @@ -83,8 +73,8 @@ async function listWorkspaces(): Promise { try { entries = await fs.readdir(auditDir); } catch { - console.log(chalk.yellow('No audit-logs directory found.')); - console.log(chalk.gray(`Expected: ${auditDir}`)); + console.log('No audit-logs directory found.'); + console.log(`Expected: ${auditDir}`); return; } @@ -110,15 +100,15 @@ async function listWorkspaces(): Promise { } if (workspaces.length === 0) { - console.log(chalk.yellow('\nNo workspaces found.')); - console.log(chalk.gray('Run a pipeline first: ./shannon start URL= REPO=')); + console.log('\nNo workspaces found.'); + console.log('Run a pipeline first: ./shannon start URL= REPO='); return; } // Sort by creation date (most recent first) workspaces.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - console.log(chalk.cyan.bold('\n=== Shannon Workspaces ===\n')); + console.log('\n=== Shannon Workspaces ===\n'); // Column widths const nameWidth = 30; @@ -129,16 +119,14 @@ async function listWorkspaces(): Promise { // Header console.log( - chalk.gray( - ' ' + - 'WORKSPACE'.padEnd(nameWidth) + - 'URL'.padEnd(urlWidth) + - 'STATUS'.padEnd(statusWidth) + - 'DURATION'.padEnd(durationWidth) + - 'COST'.padEnd(costWidth) - ) + ' ' + + 'WORKSPACE'.padEnd(nameWidth) + + 'URL'.padEnd(urlWidth) + + 'STATUS'.padEnd(statusWidth) + + 'DURATION'.padEnd(durationWidth) + + 'COST'.padEnd(costWidth) ); - console.log(chalk.gray(' ' + '\u2500'.repeat(nameWidth + urlWidth + statusWidth + durationWidth + costWidth))); + console.log(' ' + '\u2500'.repeat(nameWidth + urlWidth + statusWidth + durationWidth + costWidth)); let resumableCount = 0; @@ -154,15 +142,15 @@ async function listWorkspaces(): Promise { resumableCount++; } - const resumeTag = isResumable ? chalk.cyan(' (resumable)') : ''; + const resumeTag = isResumable ? ' (resumable)' : ''; console.log( ' ' + - chalk.white(truncate(ws.name, nameWidth - 2).padEnd(nameWidth)) + - chalk.gray(truncate(ws.url, urlWidth - 2).padEnd(urlWidth)) + - getStatusDisplay(ws.status).padEnd(statusWidth + 10) + // +10 for chalk escape codes - chalk.gray(duration.padEnd(durationWidth)) + - chalk.gray(cost.padEnd(costWidth)) + + truncate(ws.name, nameWidth - 2).padEnd(nameWidth) + + truncate(ws.url, urlWidth - 2).padEnd(urlWidth) + + getStatusDisplay(ws.status).padEnd(statusWidth) + + duration.padEnd(durationWidth) + + cost.padEnd(costWidth) + resumeTag ); } @@ -170,16 +158,16 @@ async function listWorkspaces(): Promise { console.log(); const summary = `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'} found`; const resumeSummary = resumableCount > 0 ? ` (${resumableCount} resumable)` : ''; - console.log(chalk.gray(`${summary}${resumeSummary}`)); + console.log(`${summary}${resumeSummary}`); if (resumableCount > 0) { - console.log(chalk.gray('\nResume with: ./shannon start URL= REPO= WORKSPACE=')); + console.log('\nResume with: ./shannon start URL= REPO= WORKSPACE='); } console.log(); } listWorkspaces().catch((err) => { - console.error(chalk.red('Error listing workspaces:'), err); + console.error('Error listing workspaces:', err); process.exit(1); }); diff --git a/src/types/agents.ts b/src/types/agents.ts index c9fa9ad..afa545d 100644 --- a/src/types/agents.ts +++ b/src/types/agents.ts @@ -41,7 +41,9 @@ export type PlaywrightAgent = | 'playwright-agent4' | 'playwright-agent5'; -export type AgentValidator = (sourceDir: string) => Promise; +import type { ActivityLogger } from '../temporal/activity-logger.js'; + +export type AgentValidator = (sourceDir: string, logger: ActivityLogger) => Promise; export type AgentStatus = | 'pending' diff --git a/src/utils/git-manager.ts b/src/utils/git-manager.ts index 06a1557..68cb87b 100644 --- a/src/utils/git-manager.ts +++ b/src/utils/git-manager.ts @@ -5,9 +5,9 @@ // as published by the Free Software Foundation. import { $ } from 'zx'; -import chalk from 'chalk'; import { PentestError } from '../error-handling.js'; import { ErrorCode } from '../types/errors.js'; +import type { ActivityLogger } from '../temporal/activity-logger.js'; /** * Check if a directory is a git repository. @@ -53,17 +53,19 @@ function logChangeSummary( changes: string[], messageWithChanges: string, messageWithoutChanges: string, - color: typeof chalk.green, + logger: ActivityLogger, + level: 'info' | 'warn' = 'info', maxToShow: number = 5 ): void { if (changes.length > 0) { - console.log(color(messageWithChanges.replace('{count}', String(changes.length)))); - changes.slice(0, maxToShow).forEach((change) => console.log(chalk.gray(` ${change}`))); - if (changes.length > maxToShow) { - console.log(chalk.gray(` ... and ${changes.length - maxToShow} more files`)); - } + const msg = messageWithChanges.replace('{count}', String(changes.length)); + const fileList = changes.slice(0, maxToShow).map((c) => ` ${c}`).join(', '); + const suffix = changes.length > maxToShow + ? ` ... and ${changes.length - maxToShow} more files` + : ''; + logger[level](`${msg} ${fileList}${suffix}`); } else { - console.log(color(messageWithoutChanges)); + logger[level](messageWithoutChanges); } } @@ -138,10 +140,10 @@ export async function executeGitCommandWithRetry( if (isGitLockError(errMsg) && attempt < maxRetries) { const delay = Math.pow(2, attempt - 1) * 1000; - console.log( - chalk.yellow( - ` ⚠️ Git lock conflict during ${description} (attempt ${attempt}/${maxRetries}). Retrying in ${delay}ms...` - ) + // executeGitCommandWithRetry is also called outside activity context + // (e.g., from resume logic), so we use console.warn as a fallback here + console.warn( + `Git lock conflict during ${description} (attempt ${attempt}/${maxRetries}). Retrying in ${delay}ms...` ); await new Promise((resolve) => setTimeout(resolve, delay)); continue; @@ -165,15 +167,16 @@ export async function executeGitCommandWithRetry( // Two-phase reset: hard reset (tracked files) + clean (untracked files) export async function rollbackGitWorkspace( sourceDir: string, - reason: string = 'retry preparation' + reason: string = 'retry preparation', + logger: ActivityLogger ): Promise { // Skip git operations if not a git repository if (!(await isGitRepository(sourceDir))) { - console.log(chalk.gray(` ⏭️ Skipping git rollback (not a git repository)`)); + logger.info('Skipping git rollback (not a git repository)'); return { success: true }; } - console.log(chalk.yellow(` 🔄 Rolling back workspace for ${reason}`)); + logger.info(`Rolling back workspace for ${reason}`); try { const changes = await getChangedFiles(sourceDir, 'status check for rollback'); @@ -190,15 +193,16 @@ export async function rollbackGitWorkspace( logChangeSummary( changes, - ' ✅ Rollback completed - removed {count} contaminated changes:', - ' ✅ Rollback completed - no changes to remove', - chalk.yellow, + 'Rollback completed - removed {count} contaminated changes:', + 'Rollback completed - no changes to remove', + logger, + 'info', 3 ); return { success: true }; } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - console.log(chalk.red(` ❌ Rollback failed after retries: ${errMsg}`)); + logger.error(`Rollback failed after retries: ${errMsg}`); return { success: false, error: new PentestError( @@ -216,23 +220,22 @@ export async function rollbackGitWorkspace( export async function createGitCheckpoint( sourceDir: string, description: string, - attempt: number + attempt: number, + logger: ActivityLogger ): Promise { // Skip git operations if not a git repository if (!(await isGitRepository(sourceDir))) { - console.log(chalk.gray(` ⏭️ Skipping git checkpoint (not a git repository)`)); + logger.info('Skipping git checkpoint (not a git repository)'); return { success: true }; } - console.log(chalk.blue(` 📍 Creating checkpoint for ${description} (attempt ${attempt})`)); + logger.info(`Creating checkpoint for ${description} (attempt ${attempt})`); try { // First attempt: preserve existing deliverables. Retries: clean workspace to prevent pollution if (attempt > 1) { - const cleanResult = await rollbackGitWorkspace(sourceDir, `${description} (retry cleanup)`); + const cleanResult = await rollbackGitWorkspace(sourceDir, `${description} (retry cleanup)`, logger); if (!cleanResult.success) { - console.log( - chalk.yellow(` ⚠️ Workspace cleanup failed, continuing anyway: ${cleanResult.error?.message}`) - ); + logger.warn(`Workspace cleanup failed, continuing anyway: ${cleanResult.error?.message}`); } } @@ -247,29 +250,30 @@ export async function createGitCheckpoint( ); if (hasChanges) { - console.log(chalk.blue(` ✅ Checkpoint created with uncommitted changes staged`)); + logger.info('Checkpoint created with uncommitted changes staged'); } else { - console.log(chalk.blue(` ✅ Empty checkpoint created (no workspace changes)`)); + logger.info('Empty checkpoint created (no workspace changes)'); } return { success: true }; } catch (error) { const result = toErrorResult(error); - console.log(chalk.yellow(` ⚠️ Checkpoint creation failed after retries: ${result.error?.message}`)); + logger.warn(`Checkpoint creation failed after retries: ${result.error?.message}`); return result; } } export async function commitGitSuccess( sourceDir: string, - description: string + description: string, + logger: ActivityLogger ): Promise { // Skip git operations if not a git repository if (!(await isGitRepository(sourceDir))) { - console.log(chalk.gray(` ⏭️ Skipping git commit (not a git repository)`)); + logger.info('Skipping git commit (not a git repository)'); return { success: true }; } - console.log(chalk.green(` 💾 Committing successful results for ${description}`)); + logger.info(`Committing successful results for ${description}`); try { const changes = await getChangedFiles(sourceDir, 'status check for success commit'); @@ -286,15 +290,14 @@ export async function commitGitSuccess( logChangeSummary( changes, - ' ✅ Success commit created with {count} file changes:', - ' ✅ Empty success commit created (agent made no file changes)', - chalk.green, - 5 + 'Success commit created with {count} file changes:', + 'Empty success commit created (agent made no file changes)', + logger ); return { success: true }; } catch (error) { const result = toErrorResult(error); - console.log(chalk.yellow(` ⚠️ Success commit failed after retries: ${result.error?.message}`)); + logger.warn(`Success commit failed after retries: ${result.error?.message}`); return result; } }