mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-04-01 18:30:35 +02:00
* refactor: modularize claude-executor and extract shared utilities
- Extract message handling into src/ai/message-handlers.ts with pure functions
- Extract output formatting into src/ai/output-formatters.ts
- Extract progress management into src/ai/progress-manager.ts
- Add audit-logger.ts with Null Object pattern for optional logging
- Add shared utilities: formatting.ts, file-io.ts, functional.ts
- Consolidate getPromptNameForAgent into src/types/agents.ts
* feat: add Claude Code custom commands for debug and review
* feat: add Temporal integration foundation (phase 1-2)
- Add Temporal SDK dependencies (@temporalio/client, worker, workflow, activity)
- Add shared types for pipeline state, metrics, and progress queries
- Add classifyErrorForTemporal() for retry behavior classification
- Add docker-compose for Temporal server with SQLite persistence
* feat: add Temporal activities for agent execution (phase 3)
- Add activities.ts with heartbeat loop, git checkpoint/rollback, and error classification
- Export runClaudePrompt, validateAgentOutput, ClaudePromptResult for Temporal use
- Track attempt number via Temporal Context for accurate audit logging
- Rollback git workspace before retry to ensure clean state
* feat: add Temporal workflow for 5-phase pipeline orchestration (phase 4)
* feat: add Temporal worker, client, and query tools (phase 5)
- Add worker.ts with workflow bundling and graceful shutdown
- Add client.ts CLI to start pipelines with progress polling
- Add query.ts CLI to inspect running workflow state
- Fix buffer overflow by truncating error messages and stack traces
- Skip git operations gracefully on non-git repositories
- Add kill.sh/start.sh dev scripts and Dockerfile.worker
* feat: fix Docker worker container setup
- Install uv instead of deprecated uvx package
- Add mcp-server and configs directories to container
- Mount target repo dynamically via TARGET_REPO env variable
* fix: add report assembly step to Temporal workflow
- Add assembleReportActivity to concatenate exploitation evidence files before report agent runs
- Call assembleFinalReport in workflow Phase 5 before runReportAgent
- Ensure deliverables directory exists before writing final report
- Simplify pipeline-testing report prompt to just prepend header
* refactor: consolidate Docker setup to root docker-compose.yml
* feat: improve Temporal client UX and env handling
- Change default to fire-and-forget (--wait flag to opt-in)
- Add splash screen and improve console output formatting
- Add .env to gitignore, remove from dockerignore for container access
- Add Taskfile for common development commands
* refactor: simplify session ID handling and improve Taskfile options
- Include hostname in workflow ID for better audit log organization
- Extract sanitizeHostname utility to audit/utils.ts for reuse
- Remove unused generateSessionLogPath and buildLogFilePath functions
- Simplify Taskfile with CONFIG/OUTPUT/CLEAN named parameters
* chore: add .env.example and simplify .gitignore
* docs: update README and CLAUDE.md for Temporal workflow usage
- Replace Docker CLI instructions with Task-based commands
- Add monitoring/stopping sections and workflow examples
- Document Temporal orchestration layer and troubleshooting
- Simplify file structure to key files overview
* refactor: replace Taskfile with bash CLI script
- Add shannon bash script with start/logs/query/stop/help commands
- Remove Taskfile.yml dependency (no longer requires Task installation)
- Update README.md and CLAUDE.md to use ./shannon commands
- Update client.ts output to show ./shannon commands
* docs: fix deliverable filename in README
* refactor: remove direct CLI and .shannon-store.json in favor of Temporal
- Delete src/shannon.ts direct CLI entry point (Temporal is now the only mode)
- Remove .shannon-store.json session lock (Temporal handles workflow deduplication)
- Remove broken scripts/export-metrics.js (imported non-existent function)
- Update package.json to remove main, start script, and bin entry
- Clean up CLAUDE.md and debug.md to remove obsolete references
* chore: remove licensing comments from prompt files to prevent leaking into actual prompts
* fix: resolve parallel workflow race conditions and retry logic bugs
- Fix save_deliverable race condition using closure pattern instead of global variable
- Fix error classification order so OutputValidationError matches before generic validation
- Fix ApplicationFailure re-classification bug by checking instanceof before re-throwing
- Add per-error-type retry limits (3 for output validation, 50 for billing)
- Add fast retry intervals for pipeline testing mode (10s vs 5min)
- Increase worker concurrent activities to 25 for parallel workflows
* refactor: pipeline vuln→exploit workflow for parallel execution
- Replace sync barrier between vuln/exploit phases with independent pipelines
- Each vuln type runs: vuln agent → queue check → conditional exploit
- Add checkExploitationQueue activity to skip exploits when no vulns found
- Use Promise.allSettled for graceful failure handling across pipelines
- Add PipelineSummary type for aggregated cost/duration/turns metrics
* fix: re-throw retryable errors in checkExploitationQueue
* fix: detect and retry on Claude Code spending cap errors
- Add spending cap pattern detection in detectApiError() with retryable error
- Add matching patterns to classifyErrorForTemporal() for proper Temporal retry
- Add defense-in-depth safeguard in runClaudePrompt() for $0 cost / low turn detection
- Add final sanity check in activities before declaring success
* fix: increase heartbeat timeout to prevent false worker-dead detection
Original 30s timeout was from POC spec assuming <5min activities. With
hour-long activities and multiple concurrent workflows sharing one worker,
resource contention causes event loop stalls exceeding 30s, triggering
false heartbeat timeouts. Increased to 10min (prod) and 5min (testing).
* fix: temporal db init
* fix: persist home dir
* feat: add per-workflow unified logging with ./shannon logs ID=<workflow-id>
- Add WorkflowLogger class for human-readable, per-workflow log files
- Create workflow.log in audit-logs/{workflowId}/ with phase, agent, tool, and LLM events
- Update ./shannon logs to require ID param and tail specific workflow log
- Add phase transition logging at workflow boundaries
- Include workflow completion summary with agent breakdown (duration, cost)
- Mount audit-logs volume in docker-compose for host access
---------
Co-authored-by: ezl-keygraph <ezhil@keygraph.io>
320 lines
9.3 KiB
TypeScript
320 lines
9.3 KiB
TypeScript
// 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 chalk from 'chalk';
|
|
import { fs, path } from 'zx';
|
|
import type {
|
|
PentestErrorType,
|
|
PentestErrorContext,
|
|
LogEntry,
|
|
ToolErrorResult,
|
|
PromptErrorResult,
|
|
} from './types/errors.js';
|
|
|
|
// Temporal error classification for ApplicationFailure wrapping
|
|
export interface TemporalErrorClassification {
|
|
type: string;
|
|
retryable: boolean;
|
|
}
|
|
|
|
// Custom error class for pentest operations
|
|
export class PentestError extends Error {
|
|
name = 'PentestError' as const;
|
|
type: PentestErrorType;
|
|
retryable: boolean;
|
|
context: PentestErrorContext;
|
|
timestamp: string;
|
|
|
|
constructor(
|
|
message: string,
|
|
type: PentestErrorType,
|
|
retryable: boolean = false,
|
|
context: PentestErrorContext = {}
|
|
) {
|
|
super(message);
|
|
this.type = type;
|
|
this.retryable = retryable;
|
|
this.context = context;
|
|
this.timestamp = new Date().toISOString();
|
|
}
|
|
}
|
|
|
|
// Centralized error logging function
|
|
export async function logError(
|
|
error: Error & { type?: PentestErrorType; retryable?: boolean; context?: PentestErrorContext },
|
|
contextMsg: string,
|
|
sourceDir: string | null = null
|
|
): Promise<LogEntry> {
|
|
const timestamp = new Date().toISOString();
|
|
const logEntry: LogEntry = {
|
|
timestamp,
|
|
context: contextMsg,
|
|
error: {
|
|
name: error.name || error.constructor.name,
|
|
message: error.message,
|
|
type: error.type || 'unknown',
|
|
retryable: error.retryable || false,
|
|
},
|
|
};
|
|
// Only add stack if it exists
|
|
if (error.stack) {
|
|
logEntry.error.stack = error.stack;
|
|
}
|
|
|
|
// Console logging with color
|
|
const prefix = error.retryable ? '⚠️' : '❌';
|
|
const color = error.retryable ? chalk.yellow : chalk.red;
|
|
console.log(color(`${prefix} ${contextMsg}:`));
|
|
console.log(color(` ${error.message}`));
|
|
|
|
if (error.context && Object.keys(error.context).length > 0) {
|
|
console.log(chalk.gray(` Context: ${JSON.stringify(error.context)}`));
|
|
}
|
|
|
|
// File logging (if source directory available)
|
|
if (sourceDir) {
|
|
try {
|
|
const logPath = path.join(sourceDir, 'error.log');
|
|
await fs.appendFile(logPath, JSON.stringify(logEntry) + '\n');
|
|
} catch (logErr) {
|
|
const errMsg = logErr instanceof Error ? logErr.message : String(logErr);
|
|
console.log(chalk.gray(` (Failed to write error log: ${errMsg})`));
|
|
}
|
|
}
|
|
|
|
return logEntry;
|
|
}
|
|
|
|
// Handle tool execution errors
|
|
export function handleToolError(
|
|
toolName: string,
|
|
error: Error & { code?: string }
|
|
): ToolErrorResult {
|
|
const isRetryable =
|
|
error.code === 'ECONNRESET' ||
|
|
error.code === 'ETIMEDOUT' ||
|
|
error.code === 'ENOTFOUND';
|
|
|
|
return {
|
|
tool: toolName,
|
|
output: `Error: ${error.message}`,
|
|
status: 'error',
|
|
duration: 0,
|
|
success: false,
|
|
error: new PentestError(
|
|
`${toolName} execution failed: ${error.message}`,
|
|
'tool',
|
|
isRetryable,
|
|
{ toolName, originalError: error.message, errorCode: error.code }
|
|
),
|
|
};
|
|
}
|
|
|
|
// Handle prompt loading errors
|
|
export function handlePromptError(
|
|
promptName: string,
|
|
error: Error
|
|
): PromptErrorResult {
|
|
return {
|
|
success: false,
|
|
error: new PentestError(
|
|
`Failed to load prompt '${promptName}': ${error.message}`,
|
|
'prompt',
|
|
false,
|
|
{ promptName, originalError: error.message }
|
|
),
|
|
};
|
|
}
|
|
|
|
// Patterns that indicate retryable errors
|
|
const RETRYABLE_PATTERNS = [
|
|
// Network and connection errors
|
|
'network',
|
|
'connection',
|
|
'timeout',
|
|
'econnreset',
|
|
'enotfound',
|
|
'econnrefused',
|
|
// Rate limiting
|
|
'rate limit',
|
|
'429',
|
|
'too many requests',
|
|
// Server errors
|
|
'server error',
|
|
'5xx',
|
|
'internal server error',
|
|
'service unavailable',
|
|
'bad gateway',
|
|
// Claude API errors
|
|
'mcp server',
|
|
'model unavailable',
|
|
'service temporarily unavailable',
|
|
'api error',
|
|
'terminated',
|
|
// Max turns
|
|
'max turns',
|
|
'maximum turns',
|
|
];
|
|
|
|
// Patterns that indicate non-retryable errors (checked before default)
|
|
const NON_RETRYABLE_PATTERNS = [
|
|
'authentication',
|
|
'invalid prompt',
|
|
'out of memory',
|
|
'permission denied',
|
|
'session limit reached',
|
|
'invalid api key',
|
|
];
|
|
|
|
// Conservative retry classification - unknown errors don't retry (fail-safe default)
|
|
export function isRetryableError(error: Error): boolean {
|
|
const message = error.message.toLowerCase();
|
|
|
|
// Check for explicit non-retryable patterns first
|
|
if (NON_RETRYABLE_PATTERNS.some((pattern) => message.includes(pattern))) {
|
|
return false;
|
|
}
|
|
|
|
// Check for retryable patterns
|
|
return RETRYABLE_PATTERNS.some((pattern) => message.includes(pattern));
|
|
}
|
|
|
|
// Rate limit errors get longer base delay (30s) vs standard exponential backoff (2s)
|
|
export function getRetryDelay(error: Error, attempt: number): number {
|
|
const message = error.message.toLowerCase();
|
|
|
|
// Rate limiting gets longer delays
|
|
if (message.includes('rate limit') || message.includes('429')) {
|
|
return Math.min(30000 + attempt * 10000, 120000); // 30s, 40s, 50s, max 2min
|
|
}
|
|
|
|
// Exponential backoff with jitter for other retryable errors
|
|
const baseDelay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
|
|
const jitter = Math.random() * 1000; // 0-1s random
|
|
return Math.min(baseDelay + jitter, 30000); // Max 30s
|
|
}
|
|
|
|
/**
|
|
* Classifies errors for Temporal workflow retry behavior.
|
|
* Returns error type and whether Temporal should retry.
|
|
*
|
|
* Used by activities to wrap errors in ApplicationFailure:
|
|
* - Retryable errors: Temporal retries with configured backoff
|
|
* - Non-retryable errors: Temporal fails immediately
|
|
*/
|
|
export function classifyErrorForTemporal(error: unknown): TemporalErrorClassification {
|
|
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
|
|
// === BILLING ERRORS (Retryable with long backoff) ===
|
|
// Anthropic returns billing as 400 invalid_request_error
|
|
// Human can add credits OR wait for spending cap to reset (5-30 min backoff)
|
|
if (
|
|
message.includes('billing_error') ||
|
|
message.includes('credit balance is too low') ||
|
|
message.includes('insufficient credits') ||
|
|
message.includes('usage is blocked due to insufficient credits') ||
|
|
message.includes('please visit plans & billing') ||
|
|
message.includes('please visit plans and billing') ||
|
|
message.includes('usage limit reached') ||
|
|
message.includes('quota exceeded') ||
|
|
message.includes('daily rate limit') ||
|
|
message.includes('limit will reset') ||
|
|
// Claude Code spending cap patterns (returns short message instead of error)
|
|
message.includes('spending cap') ||
|
|
message.includes('spending limit') ||
|
|
message.includes('cap reached') ||
|
|
message.includes('budget exceeded') ||
|
|
message.includes('billing limit reached')
|
|
) {
|
|
return { type: 'BillingError', retryable: true };
|
|
}
|
|
|
|
// === PERMANENT ERRORS (Non-retryable) ===
|
|
|
|
// Authentication (401) - bad API key won't fix itself
|
|
if (
|
|
message.includes('authentication') ||
|
|
message.includes('api key') ||
|
|
message.includes('401') ||
|
|
message.includes('authentication_error')
|
|
) {
|
|
return { type: 'AuthenticationError', retryable: false };
|
|
}
|
|
|
|
// Permission (403) - access won't be granted
|
|
if (
|
|
message.includes('permission') ||
|
|
message.includes('forbidden') ||
|
|
message.includes('403')
|
|
) {
|
|
return { type: 'PermissionError', retryable: false };
|
|
}
|
|
|
|
// === OUTPUT VALIDATION ERRORS (Retryable) ===
|
|
// Agent didn't produce expected deliverables - retry may succeed
|
|
// IMPORTANT: Must come BEFORE generic 'validation' check below
|
|
if (
|
|
message.includes('failed output validation') ||
|
|
message.includes('output validation failed')
|
|
) {
|
|
return { type: 'OutputValidationError', retryable: true };
|
|
}
|
|
|
|
// Invalid Request (400) - malformed request is permanent
|
|
// Note: Checked AFTER billing and AFTER output validation
|
|
if (
|
|
message.includes('invalid_request_error') ||
|
|
message.includes('malformed') ||
|
|
message.includes('validation')
|
|
) {
|
|
return { type: 'InvalidRequestError', retryable: false };
|
|
}
|
|
|
|
// Request Too Large (413) - won't fit no matter how many retries
|
|
if (
|
|
message.includes('request_too_large') ||
|
|
message.includes('too large') ||
|
|
message.includes('413')
|
|
) {
|
|
return { type: 'RequestTooLargeError', retryable: false };
|
|
}
|
|
|
|
// Configuration errors - missing files need manual fix
|
|
if (
|
|
message.includes('enoent') ||
|
|
message.includes('no such file') ||
|
|
message.includes('cli not installed')
|
|
) {
|
|
return { type: 'ConfigurationError', retryable: false };
|
|
}
|
|
|
|
// Execution limits - max turns/budget reached
|
|
if (
|
|
message.includes('max turns') ||
|
|
message.includes('budget') ||
|
|
message.includes('execution limit') ||
|
|
message.includes('error_max_turns') ||
|
|
message.includes('error_max_budget')
|
|
) {
|
|
return { type: 'ExecutionLimitError', retryable: false };
|
|
}
|
|
|
|
// Invalid target URL - bad URL format won't fix itself
|
|
if (
|
|
message.includes('invalid url') ||
|
|
message.includes('invalid target') ||
|
|
message.includes('malformed url') ||
|
|
message.includes('invalid uri')
|
|
) {
|
|
return { type: 'InvalidTargetError', retryable: false };
|
|
}
|
|
|
|
// === TRANSIENT ERRORS (Retryable) ===
|
|
// Rate limits (429), server errors (5xx), network issues
|
|
// Let Temporal retry with configured backoff
|
|
return { type: 'TransientError', retryable: true };
|
|
}
|