mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-04-01 10:20:53 +02:00
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:
@@ -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') {
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
43
src/temporal/activity-logger.ts
Normal file
43
src/temporal/activity-logger.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user