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

View File

@@ -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<typeof createShannonHelperServer> | 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<string, McpServer> {
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<boolean> {
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<ClaudePromptResult> {
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<string, string> = {
@@ -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<typeof detectExecutionContext>;
description: string;
colorFn: ChalkInstance;
progress: ReturnType<typeof createProgressManager>;
auditLogger: ReturnType<typeof createAuditLogger>;
logger: ActivityLogger;
}
async function processMessageStream(
@@ -339,7 +339,7 @@ async function processMessageStream(
deps: MessageLoopDeps,
timer: Timer
): Promise<MessageLoopResult> {
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') {

View File

@@ -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<MessageDispatchAction> {
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' };
}
}

View File

@@ -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;

View File

@@ -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<boolean> => {
return async (sourceDir: string, logger: ActivityLogger): Promise<boolean> => {
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<AgentName, AgentValidator> = Object.freeze
'authz-exploit': createExploitValidator('authz'),
// Executive report agent
report: async (sourceDir: string): Promise<boolean> => {
report: async (sourceDir: string, logger: ActivityLogger): Promise<boolean> => {
const reportFile = path.join(
sourceDir,
'deliverables',
@@ -101,9 +101,7 @@ export const AGENT_VALIDATORS: Record<AgentName, AgentValidator> = 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;

View File

@@ -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<string> {
export async function assembleFinalReport(sourceDir: string, logger: ActivityLogger): Promise<string> {
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<string> {
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<string> {
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<string> {
// 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<string> {
*/
export async function injectModelIntoReport(
repoPath: string,
outputPath: string
outputPath: string,
logger: ActivityLogger
): Promise<void> {
// 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;
}
}

View File

@@ -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}`);
}
}

View File

@@ -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<string> {
async function buildLoginInstructions(authentication: Authentication, logger: ActivityLogger): Promise<string> {
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<s
// Fallback to full template if markers are missing (backward compatibility)
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;
} else {
// Combine relevant sections
@@ -128,7 +128,8 @@ async function processIncludes(content: string, baseDir: string): Promise<string
async function interpolateVariables(
template: string,
variables: PromptVariables,
config: DistributedConfig | null = null
config: DistributedConfig | null = null,
logger: ActivityLogger
): Promise<string> {
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<string> {
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;

View File

@@ -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<Result<AgentEndResult, PentestError>> {
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<AgentEndResult> {
const result = await this.execute(agentName, input, auditSession);
const result = await this.execute(agentName, input, auditSession, logger);
if (isErr(result)) {
throw result.error;
}

View File

@@ -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<ExploitationDecision> {
async checkQueue(vulnType: VulnType, repoPath: string, logger: ActivityLogger): Promise<ExploitationDecision> {
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,

View File

@@ -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<AgentMetrics
*/
export async function assembleReportActivity(input: ActivityInput): Promise<void> {
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<void
*/
export async function injectReportMetadataActivity(input: ActivityInput): Promise<void> {
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<ExploitationDecision> {
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<void> {
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

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();
}

View File

@@ -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 <webUrl> <repoPath> [options]\n'
);
console.log(chalk.yellow('Options:'));
console.log('Options:');
console.log(' --config <path> Configuration file path');
console.log(' --output <path> Output directory for audit logs');
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>)'
);
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<void> {
}
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<void> {
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<void> {
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<SessionJson>(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<void> {
} 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<void> {
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<void> {
);
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<void> {
const progress = await handle.query<PipelineProgress>(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<void> {
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<void> {
const session = await readJson<SessionJson>(
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<void> {
}
} 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<void> {
}
startPipeline().catch((err) => {
console.error(chalk.red('Client error:'), err);
console.error('Client error:', err);
process.exit(1);
});

View File

@@ -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<void> {
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<void> {
// Graceful shutdown handling
const shutdown = async (): Promise<void> => {
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);
});

View File

@@ -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');
}

View File

@@ -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<void> {
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<void> {
}
if (workspaces.length === 0) {
console.log(chalk.yellow('\nNo workspaces found.'));
console.log(chalk.gray('Run a pipeline first: ./shannon start URL=<url> REPO=<repo>'));
console.log('\nNo workspaces found.');
console.log('Run a pipeline first: ./shannon start URL=<url> REPO=<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<void> {
// 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<void> {
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<void> {
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=<url> REPO=<repo> WORKSPACE=<name>'));
console.log('\nResume with: ./shannon start URL=<url> REPO=<repo> WORKSPACE=<name>');
}
console.log();
}
listWorkspaces().catch((err) => {
console.error(chalk.red('Error listing workspaces:'), err);
console.error('Error listing workspaces:', err);
process.exit(1);
});

View File

@@ -41,7 +41,9 @@ export type PlaywrightAgent =
| 'playwright-agent4'
| '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 =
| 'pending'

View File

@@ -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<GitOperationResult> {
// 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<GitOperationResult> {
// 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<GitOperationResult> {
// 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;
}
}