refactor: replace console.log/chalk with ActivityLogger across services

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