refactor: remove orchestration layer (#45)

* refactor: remove orchestration layer and simplify CLI

Remove the complex orchestration layer including checkpoint management,
rollback/recovery commands, and session management commands. This
consolidates the execution logic directly in shannon.ts for a simpler
fire-and-forget execution model.

Changes:
- Remove checkpoint-manager.ts and rollback functionality
- Remove command-handler.ts and cli/prompts.ts
- Simplify session-manager.ts to just agent definitions
- Consolidate orchestration logic in shannon.ts
- Update CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: move session lock logic to shannon.ts, simplify session-manager

- Reduce session-manager.ts to only AGENTS, AGENT_ORDER, getParallelGroups()
- Move Session interface and lock file functions to shannon.ts
- Simplify Session to only: id, webUrl, repoPath, status, startedAt
- Remove unused types/session.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: use crypto.randomUUID() for session ID generation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ezl-keygraph
2026-01-12 22:58:17 +05:30
committed by GitHub
parent 264b16991a
commit bc52d67dd5
15 changed files with 682 additions and 2496 deletions

148
CLAUDE.md
View File

@@ -30,6 +30,15 @@ shannon "https://example.com" "/path/to/repo" --output /path/to/reports
npm start <WEB_URL> <REPO_PATH> --config <CONFIG_FILE>
```
### Options
```bash
--config <file> YAML configuration file for authentication and testing parameters
--output <path> Custom output directory for session folder (default: ./audit-logs/)
--pipeline-testing Use minimal prompts for fast pipeline testing (creates minimal deliverables)
--disable-loader Disable the animated progress loader (useful when logs interfere with spinner)
--help Show help message
```
### Configuration Validation
```bash
# Configuration validation is built into the main script
@@ -43,58 +52,7 @@ TOTP generation is now handled automatically via the `generate_totp` MCP tool du
```bash
# No linting or testing commands available in this project
# Development is done by running the agent in pipeline-testing mode
shannon <commands> --pipeline-testing
```
### Session Management Commands
```bash
# Setup session without running
shannon --setup-only <WEB_URL> <REPO_PATH> --config <CONFIG_FILE>
# Check session status (shows progress, timing, costs)
shannon --status
# List all available agents by phase
shannon --list-agents
# Show help
shannon --help
```
### Execution Commands
```bash
# Run all remaining agents to completion
shannon --run-all [--pipeline-testing]
# Run a specific agent
shannon --run-agent <agent-name> [--pipeline-testing]
# Run a range of agents
shannon --run-agents <start-agent>:<end-agent> [--pipeline-testing]
# Run a specific phase
shannon --run-phase <phase-name> [--pipeline-testing]
# Pipeline testing mode (minimal prompts for fast testing)
shannon <command> --pipeline-testing
```
### Rollback & Recovery Commands
```bash
# Rollback to specific checkpoint
shannon --rollback-to <agent-name>
# Rollback and re-execute specific agent
shannon --rerun <agent-name> [--pipeline-testing]
```
### Session Cleanup Commands
```bash
# Delete all sessions (with confirmation)
shannon --cleanup
# Delete specific session by ID
shannon --cleanup <session-id>
shannon <WEB_URL> <REPO_PATH> --pipeline-testing
```
## Architecture & Components
@@ -106,22 +64,20 @@ shannon --cleanup <session-id>
- `src/config-parser.ts` - Handles YAML configuration parsing, validation, and distribution to agents
- `src/error-handling.ts` - Comprehensive error handling with retry logic and categorized error types
- `src/tool-checker.ts` - Validates availability of external security tools before execution
- `src/session-manager.ts` - Manages persistent session state and agent lifecycle
- `src/checkpoint-manager.ts` - Git-based checkpointing system for rollback capabilities
- Pipeline orchestration is built into the main `src/shannon.ts` script
- `src/session-manager.ts` - Agent definitions, execution order, and parallel groups
- `src/queue-validation.ts` - Validates deliverables and agent prerequisites
### Five-Phase Testing Workflow
1. **Pre-Reconnaissance** (`pre-recon`) - External tool scans (nmap, subfinder, whatweb) + source code analysis
2. **Reconnaissance** (`recon`) - Analysis of initial findings and attack surface mapping
3. **Vulnerability Analysis** (5 agents)
3. **Vulnerability Analysis** (5 agents run in parallel)
- `injection-vuln` - SQL injection, command injection
- `xss-vuln` - Cross-site scripting
- `auth-vuln` - Authentication bypasses
- `authz-vuln` - Authorization flaws
- `ssrf-vuln` - Server-side request forgery
4. **Exploitation** (5 agents)
4. **Exploitation** (5 agents run in parallel, only if vulnerabilities found)
- `injection-exploit` - Exploit injection vulnerabilities
- `xss-exploit` - Exploit XSS vulnerabilities
- `auth-exploit` - Exploit authentication issues
@@ -182,45 +138,25 @@ The agent integrates with external security tools:
Tools are validated for availability before execution using the tool-checker module.
### Git-Based Checkpointing System
The agent implements a sophisticated checkpoint system using git:
- Every agent creates a git checkpoint before execution
- Rollback to any previous agent state using `--rollback-to` or `--rerun`
- Failed agents don't affect completed work
- Rolled-back agents marked in audit system with status: "rolled-back"
- Reconciliation automatically syncs Shannon store with audit logs after rollback
- Fail-fast safety prevents accidental re-execution of completed agents
### Unified Audit & Metrics System
The agent implements a crash-safe, self-healing audit system (v3.0) with the following guarantees:
### Audit & Metrics System
The agent implements a crash-safe audit system with the following features:
**Architecture:**
- **audit-logs/** (or custom `--output` path): Centralized metrics and forensic logs (source of truth)
- **audit-logs/** (or custom `--output` path): Centralized metrics and forensic logs
- `{hostname}_{sessionId}/session.json` - Comprehensive metrics with attempt-level detail
- `{hostname}_{sessionId}/prompts/` - Exact prompts used for reproducibility
- `{hostname}_{sessionId}/agents/` - Turn-by-turn execution logs
- `{hostname}_{sessionId}/deliverables/` - Security reports and findings
- **.shannon-store.json**: Minimal orchestration state (completedAgents, checkpoints)
- **.shannon-store.json**: Minimal session lock file (prevents concurrent runs)
**Crash Safety:**
- Append-only logging with immediate flush (survives kill -9)
- Atomic writes for session.json (no partial writes)
- Event-based logging (tool_start, tool_end, llm_response) closes data loss windows
**Self-Healing:**
- Automatic reconciliation before every CLI command
- Recovers from crashes during rollback
- Audit logs are source of truth; Shannon store follows
**Forensic Completeness:**
- All retry attempts logged with errors, costs, durations
- Rolled-back agents preserved with status: "rolled-back"
- Partial cost capture for failed attempts
- Complete event trail for debugging
- Event-based logging (tool_start, tool_end, llm_response)
**Concurrency Safety:**
- SessionMutex prevents race conditions during parallel agent execution
- Safe parallel execution of vulnerability and exploitation phases
- 5x faster execution with parallel vulnerability and exploitation phases
**Metrics & Reporting:**
- Export metrics to CSV with `./scripts/export-metrics.js`
@@ -238,16 +174,20 @@ For detailed design, see `docs/unified-audit-system-design.md`.
- **SDK-First Approach**: Heavy reliance on Claude Agent SDK for autonomous AI operations
- **Progressive Analysis**: Each phase builds on previous phase results
- **Local Repository Setup**: Target applications are accessed directly from user-provided local directories
- **Fire-and-Forget Execution**: Single entry point, runs all phases to completion
### Error Handling Strategy
The application uses a comprehensive error handling system with:
- Categorized error types (PentestError, ConfigError, NetworkError, etc.)
- Automatic retry logic for transient failures
- Automatic retry logic for transient failures (3 attempts per agent)
- Graceful degradation when external tools are unavailable
- Detailed error logging and user-friendly error messages
### Testing Mode
The agent includes a testing mode that skips external tool execution for faster development cycles.
The agent includes a testing mode that skips external tool execution for faster development cycles:
```bash
shannon <WEB_URL> <REPO_PATH> --pipeline-testing
```
### Security Focus
This is explicitly designed as a **defensive security tool** for:
@@ -263,32 +203,48 @@ The tool should only be used on systems you own or have explicit permission to t
```
src/ # TypeScript source files
├── shannon.ts # Main orchestration script (entry point)
├── constants.ts # Shared constants
├── config-parser.ts # Configuration handling
├── error-handling.ts # Error management
├── tool-checker.ts # Tool validation
├── session-manager.ts # Agent definitions, order, and parallel groups
├── queue-validation.ts # Deliverable validation
├── splash-screen.ts # ASCII art splash screen
├── progress-indicator.ts # Progress display utilities
├── types/ # TypeScript type definitions
│ ├── index.ts # Barrel exports
│ ├── agents.ts # Agent type definitions
│ ├── config.ts # Configuration interfaces
│ ├── errors.ts # Error type definitions
│ └── session.ts # Session type definitions
├── audit/ # Unified audit system (v3.0)
├── audit/ # Audit system
│ ├── index.ts # Public API
│ ├── audit-session.ts # Main facade (logger + metrics + mutex)
│ ├── logger.ts # Append-only crash-safe logging
│ ├── metrics-tracker.ts # Timing, cost, attempt tracking
│ └── utils.ts # Path generation, atomic writes
├── config-parser.ts # Configuration handling
├── error-handling.ts # Error management
├── tool-checker.ts # Tool validation
├── session-manager.ts # Session state + reconciliation
├── checkpoint-manager.ts # Git-based checkpointing + rollback
├── queue-validation.ts # Deliverable validation
├── ai/
│ └── claude-executor.ts # Claude Agent SDK integration
├── phases/
│ ├── pre-recon.ts # Pre-reconnaissance phase
│ └── reporting.ts # Final report assembly
├── prompts/
│ └── prompt-manager.ts # Prompt loading and variable substitution
├── setup/
│ └── environment.ts # Local repository setup
├── cli/
│ ├── ui.ts # Help text display
│ └── input-validator.ts # URL and path validation
└── utils/
├── git-manager.ts # Git operations
├── metrics.ts # Timing utilities
├── output-formatter.ts # Output formatting utilities
└── concurrency.ts # SessionMutex for parallel execution
dist/ # Compiled JavaScript output
├── shannon.js # Compiled entry point
└── ... # Other compiled files
package.json # Node.js dependencies
.shannon-store.json # Orchestration state (minimal)
.shannon-store.json # Session lock file
audit-logs/ # Centralized audit data (default, or use --output)
└── {hostname}_{sessionId}/
├── session.json # Comprehensive metrics
@@ -329,11 +285,9 @@ docs/ # Documentation
## Troubleshooting
### Common Issues
- **"Agent already completed"**: Use `--rerun <agent>` for explicit re-execution
- **"Missing prerequisites"**: Check `--status` and run prerequisite agents first
- **"No sessions found"**: Create a session with `--setup-only` first
- **"A session is already running"**: Wait for the current session to complete, or delete `.shannon-store.json`
- **"Repository not found"**: Ensure target local directory exists and is accessible
- **"Too many test sessions"**: Use `--cleanup` to remove old sessions and free disk space
- **Concurrent runs blocked**: Only one session can run at a time per target
### External Tool Dependencies
Missing tools can be skipped using `--pipeline-testing` mode during development:

View File

@@ -286,14 +286,6 @@ rules:
If your application uses two-factor authentication, simply add the TOTP secret to your config file. The AI will automatically generate the required codes during testing.
### Check Status
View progress of previous runs:
```bash
docker run --rm shannon:latest --status
```
### Output and Results
All results are saved to `./audit-logs/` by default. Use `--output <path>` to specify a custom directory. If using `--output`, ensure that path is mounted to an accessible host directory (e.g., `-v "$(pwd)/custom-directory:/app/reports"`).

View File

@@ -155,21 +155,6 @@ export class AuditSession {
}
}
/**
* Mark multiple agents as rolled back
*/
async markMultipleRolledBack(agentNames: string[]): Promise<void> {
await this.ensureInitialized();
const unlock = await sessionMutex.lock(this.sessionId);
try {
await this.metricsTracker.reload();
await this.metricsTracker.markMultipleRolledBack(agentNames);
} finally {
unlock();
}
}
/**
* Update session status
*/

View File

@@ -20,7 +20,6 @@ import {
calculatePercentage,
type SessionMetadata,
} from './utils.js';
import type { AgentName, PhaseName } from '../types/index.js';
interface AttemptData {
attempt_number: number;
@@ -32,12 +31,11 @@ interface AttemptData {
}
interface AgentMetrics {
status: 'in-progress' | 'success' | 'failed' | 'rolled-back';
status: 'in-progress' | 'success' | 'failed';
attempts: AttemptData[];
final_duration_ms: number;
total_cost_usd: number;
checkpoint?: string;
rolled_back_at?: string;
}
interface PhaseMetrics {
@@ -208,42 +206,6 @@ export class MetricsTracker {
await this.save();
}
/**
* Mark agent as rolled back
*/
async markRolledBack(agentName: string): Promise<void> {
if (!this.data || !this.data.metrics.agents[agentName]) {
return; // Agent not tracked
}
const agent = this.data.metrics.agents[agentName]!;
agent.status = 'rolled-back';
agent.rolled_back_at = formatTimestamp();
// Recalculate aggregations (exclude rolled-back agents)
this.recalculateAggregations();
await this.save();
}
/**
* Mark multiple agents as rolled back
*/
async markMultipleRolledBack(agentNames: string[]): Promise<void> {
if (!this.data) return;
for (const agentName of agentNames) {
if (this.data.metrics.agents[agentName]) {
const agent = this.data.metrics.agents[agentName]!;
agent.status = 'rolled-back';
agent.rolled_back_at = formatTimestamp();
}
}
this.recalculateAggregations();
await this.save();
}
/**
* Update session status
*/
@@ -267,7 +229,7 @@ export class MetricsTracker {
const agents = this.data.metrics.agents;
// Only count successful agents (not rolled-back or failed)
// Only count successful agents
const successfulAgents = Object.entries(agents).filter(
([, data]) => data.status === 'success'
);

View File

@@ -1,936 +0,0 @@
// 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 { fs, path } from 'zx';
import chalk, { type ChalkInstance } from 'chalk';
import { PentestError } from './error-handling.js';
import { parseConfig, distributeConfig } from './config-parser.js';
import { executeGitCommandWithRetry } from './utils/git-manager.js';
import { formatDuration } from './audit/utils.js';
import {
AGENTS,
PHASES,
validateAgent,
validateAgentRange,
validatePhase,
checkPrerequisites,
getNextAgent,
markAgentCompleted,
markAgentFailed,
getSessionStatus,
rollbackToAgent,
getSession
} from './session-manager.js';
import type { Session, AgentDefinition } from './session-manager.js';
import type { AgentName, PhaseName, PromptName } from './types/index.js';
import type { DistributedConfig } from './types/config.js';
import type { SessionMetadata } from './audit/utils.js';
// Types for callback functions
type RunClaudePromptWithRetry = (
prompt: string,
sourceDir: string,
allowedTools: string,
context: string,
description: string,
agentName: string | null,
colorFn: ChalkInstance,
sessionMetadata: SessionMetadata | null
) => Promise<AgentResult>;
type LoadPrompt = (
promptName: string,
variables: { webUrl: string; repoPath: string; sourceDir?: string },
config: DistributedConfig | null,
pipelineTestingMode: boolean
) => Promise<string>;
export interface AgentResult {
success: boolean;
duration: number;
cost?: number;
partialCost?: number;
error?: string;
retryable?: boolean;
logFile?: string;
}
interface ValidationData {
shouldExploit: boolean;
vulnerabilityCount: number;
}
interface SingleAgentResult {
success: boolean;
agentName: string;
result?: AgentResult;
validation?: ValidationData | null;
timing?: number | null;
cost?: number | null;
checkpoint?: string;
completedAt?: string;
attempts?: number;
logFile?: string;
error?: {
message: string;
type: string;
retryable: boolean;
originalError?: Error;
};
failedAt?: string;
context?: {
targetRepo: string;
promptName: PromptName;
sessionId: string;
};
}
interface ParallelResult {
completed: AgentName[];
failed: Array<{ agent: AgentName; error: string }>;
}
// Check if target repository exists and is accessible
const validateTargetRepo = async (targetRepo: string): Promise<boolean> => {
if (!targetRepo || !await fs.pathExists(targetRepo)) {
throw new PentestError(
`Target repository '${targetRepo}' not found or not accessible`,
'filesystem',
false,
{ targetRepo }
);
}
// Check if it's a git repository
const gitDir = path.join(targetRepo, '.git');
if (!await fs.pathExists(gitDir)) {
throw new PentestError(
`Target repository '${targetRepo}' is not a git repository`,
'validation',
false,
{ targetRepo }
);
}
return true;
};
// Get git commit hash for checkpoint
export const getGitCommitHash = async (targetRepo: string): Promise<string> => {
try {
const result = await executeGitCommandWithRetry(['git', 'rev-parse', 'HEAD'], targetRepo, 'getting commit hash');
return result.stdout.trim();
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new PentestError(
`Failed to get git commit hash: ${errMsg}`,
'validation',
false,
{ targetRepo, originalError: errMsg }
);
}
};
// Rollback git workspace to specific commit
const rollbackGitToCommit = async (targetRepo: string, commitHash: string): Promise<void> => {
try {
await executeGitCommandWithRetry(['git', 'reset', '--hard', commitHash], targetRepo, 'rollback to commit');
await executeGitCommandWithRetry(['git', 'clean', '-fd'], targetRepo, 'cleaning after rollback');
console.log(chalk.green(`✅ Git workspace rolled back to commit ${commitHash.substring(0, 8)}`));
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new PentestError(
`Failed to rollback git workspace: ${errMsg}`,
'validation',
false,
{ targetRepo, commitHash, originalError: errMsg }
);
}
};
// Helper function to get prompt name from agent name
const getPromptName = (agentName: AgentName): PromptName => {
const mappings: Record<AgentName, PromptName> = {
'pre-recon': 'pre-recon-code',
'recon': 'recon',
'injection-vuln': 'vuln-injection',
'xss-vuln': 'vuln-xss',
'auth-vuln': 'vuln-auth',
'ssrf-vuln': 'vuln-ssrf',
'authz-vuln': 'vuln-authz',
'injection-exploit': 'exploit-injection',
'xss-exploit': 'exploit-xss',
'auth-exploit': 'exploit-auth',
'ssrf-exploit': 'exploit-ssrf',
'authz-exploit': 'exploit-authz',
'report': 'report-executive'
};
return mappings[agentName] || agentName as PromptName;
};
// Get color function for agent
const getAgentColor = (agentName: AgentName): ChalkInstance => {
const colorMap: Partial<Record<AgentName, ChalkInstance>> = {
'injection-vuln': chalk.red,
'injection-exploit': chalk.red,
'xss-vuln': chalk.yellow,
'xss-exploit': chalk.yellow,
'auth-vuln': chalk.blue,
'auth-exploit': chalk.blue,
'ssrf-vuln': chalk.magenta,
'ssrf-exploit': chalk.magenta,
'authz-vuln': chalk.green,
'authz-exploit': chalk.green
};
return colorMap[agentName] || chalk.cyan;
};
// Run a single agent with retry logic and checkpointing
const runSingleAgent = async (
agentName: AgentName,
session: Session,
pipelineTestingMode: boolean,
runClaudePromptWithRetry: RunClaudePromptWithRetry,
loadPrompt: LoadPrompt,
allowRerun: boolean = false,
skipWorkspaceClean: boolean = false
): Promise<SingleAgentResult> => {
// Validate agent first
const agent = validateAgent(agentName);
console.log(chalk.cyan(`\n🤖 Running agent: ${agent.displayName}`));
// Reload session to get latest state (important for agent ranges)
const freshSession = await getSession(session.id);
if (!freshSession) {
throw new PentestError(`Session ${session.id} not found`, 'validation', false);
}
// Use fresh session for all subsequent checks
const currentSession = freshSession;
// Warn if session is completed
if (currentSession.status === 'completed') {
console.log(chalk.yellow('⚠️ This session is already completed. Re-running will modify completed results.'));
}
// Block re-running completed agents unless explicitly allowed
if (!allowRerun && currentSession.completedAgents.includes(agentName)) {
throw new PentestError(
`Agent '${agentName}' has already been completed. Use --rerun ${agentName} for explicit rollback and re-execution.`,
'validation',
false,
{
agentName,
suggestion: `--rerun ${agentName}`,
completedAgents: currentSession.completedAgents
}
);
}
const targetRepo = currentSession.targetRepo;
await validateTargetRepo(targetRepo);
// Check prerequisites
checkPrerequisites(currentSession, agentName);
// Clean workspace if needed
if (!currentSession.completedAgents.includes(agentName) && !allowRerun && !skipWorkspaceClean) {
try {
const status = await executeGitCommandWithRetry(['git', 'status', '--porcelain'], targetRepo, 'checking workspace status');
const hasUncommittedChanges = status.stdout.trim().length > 0;
if (hasUncommittedChanges) {
console.log(chalk.yellow(` ⚠️ Detected uncommitted changes before running ${agentName}`));
console.log(chalk.yellow(` 🧹 Cleaning workspace to ensure clean agent execution`));
await executeGitCommandWithRetry(['git', 'reset', '--hard', 'HEAD'], targetRepo, 'cleaning workspace');
await executeGitCommandWithRetry(['git', 'clean', '-fd'], targetRepo, 'removing untracked files');
console.log(chalk.green(` ✅ Workspace cleaned successfully`));
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
console.log(chalk.yellow(` ⚠️ Could not check/clean workspace: ${errMsg}`));
}
}
// Create variables for prompt
const variables = {
webUrl: currentSession.webUrl,
repoPath: currentSession.repoPath,
sourceDir: targetRepo
};
// Handle relative config paths
let configPath: string | null = null;
if (currentSession.configFile) {
configPath = path.isAbsolute(currentSession.configFile) || currentSession.configFile.startsWith('configs/')
? currentSession.configFile
: path.join('configs', currentSession.configFile);
}
const config = configPath ? await parseConfig(configPath) : null;
const distributedConfig = config ? distributeConfig(config) : null;
// Initialize variables for result
let validationData: ValidationData | null = null;
let timingData: number | null = null;
let costData: number | null = null;
try {
// Load and run the appropriate prompt
const promptName = getPromptName(agentName);
const prompt = await loadPrompt(promptName, variables, distributedConfig, pipelineTestingMode);
const result = await runClaudePromptWithRetry(
prompt,
targetRepo,
'*',
'',
AGENTS[agentName]!.displayName,
agentName,
getAgentColor(agentName),
{ id: currentSession.id, webUrl: currentSession.webUrl, repoPath: currentSession.repoPath, ...(currentSession.outputPath && { outputPath: currentSession.outputPath }) }
);
if (!result.success) {
throw new PentestError(
`Agent execution failed: ${result.error}`,
'validation',
result.retryable || false,
{ agentName, result }
);
}
// Get commit hash for checkpoint
const commitHash = await getGitCommitHash(targetRepo);
// Extract timing and cost data
timingData = result.duration;
costData = result.cost || 0;
if (agentName.includes('-vuln')) {
// Validate vulnerability analysis results
const vulnType = agentName.replace('-vuln', '');
try {
const { safeValidateQueueAndDeliverable } = await import('./queue-validation.js');
const validation = await safeValidateQueueAndDeliverable(vulnType as 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz', targetRepo);
if (validation.success && validation.data) {
console.log(chalk.blue(`📋 Validation: ${validation.data.shouldExploit ? `Ready for exploitation (${validation.data.vulnerabilityCount} vulnerabilities)` : 'No vulnerabilities found'}`));
validationData = {
shouldExploit: validation.data.shouldExploit,
vulnerabilityCount: validation.data.vulnerabilityCount
};
} else if (validation.error) {
console.log(chalk.yellow(`⚠️ Validation failed: ${validation.error.message}`));
}
} catch (validationError) {
const errMsg = validationError instanceof Error ? validationError.message : String(validationError);
console.log(chalk.yellow(`⚠️ Could not validate ${vulnType}: ${errMsg}`));
}
}
// Mark agent as completed
await markAgentCompleted(currentSession.id, agentName, commitHash);
// Only show completion message for sequential execution
if (!skipWorkspaceClean) {
console.log(chalk.green(`✅ Agent '${agentName}' completed successfully`));
}
// Return immutable result object
return Object.freeze({
success: true,
agentName,
result,
validation: validationData,
timing: timingData,
cost: costData,
checkpoint: commitHash,
completedAt: new Date().toISOString()
});
} catch (error) {
// Mark agent as failed
await markAgentFailed(currentSession.id, agentName);
const err = error as Error & { retryable?: boolean };
// Only show failure message for sequential execution
if (!skipWorkspaceClean) {
console.log(chalk.red(`❌ Agent '${agentName}' failed: ${err.message}`));
}
// Return immutable error object
const errorResult: SingleAgentResult = Object.freeze({
success: false,
agentName,
error: {
message: err.message,
type: err.constructor.name,
retryable: err.retryable || false,
originalError: err
},
validation: validationData,
timing: timingData,
failedAt: new Date().toISOString(),
context: {
targetRepo,
promptName: getPromptName(agentName),
sessionId: currentSession.id
}
});
// Throw enhanced error
const enhancedError = new PentestError(
`Agent '${agentName}' execution failed: ${err.message}`,
'validation',
err.retryable || false,
{
agentName,
sessionId: currentSession.id,
originalError: err.message,
errorResult
}
);
throw enhancedError;
}
};
// Run vulnerability agents in parallel
const runParallelVuln = async (
session: Session,
pipelineTestingMode: boolean,
runClaudePromptWithRetry: RunClaudePromptWithRetry,
loadPrompt: LoadPrompt
): Promise<ParallelResult> => {
const vulnAgents: AgentName[] = ['injection-vuln', 'xss-vuln', 'auth-vuln', 'ssrf-vuln', 'authz-vuln'];
const activeAgents = vulnAgents.filter(agent => !session.completedAgents.includes(agent));
if (activeAgents.length === 0) {
console.log(chalk.gray('⏭️ All vulnerability agents already completed'));
return { completed: vulnAgents, failed: [] };
}
console.log(chalk.cyan(`\n🚀 Starting ${activeAgents.length} vulnerability analysis specialists in parallel...`));
console.log(chalk.gray(' Specialists: ' + activeAgents.join(', ')));
console.log();
const startTime = Date.now();
// Collect all results without logging individual completions
const results = await Promise.allSettled(
activeAgents.map(async (agentName, index) => {
// Add 2-second stagger to prevent API overwhelm
await new Promise(resolve => setTimeout(resolve, index * 2000));
let lastError: Error | undefined;
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
attempts++;
try {
const result = await runSingleAgent(agentName, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, false, true);
return { ...result, attempts };
} catch (error) {
lastError = error as Error;
if (attempts < maxAttempts) {
console.log(chalk.yellow(`⚠️ ${agentName} failed attempt ${attempts}/${maxAttempts}, retrying...`));
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
throw { agentName, error: lastError, attempts };
})
);
const totalDuration = Date.now() - startTime;
// Process and display results
console.log(chalk.cyan('\n📊 Vulnerability Analysis Results'));
console.log(chalk.gray('─'.repeat(80)));
console.log(chalk.bold('Agent Status Vulns Attempt Duration Cost'));
console.log(chalk.gray('─'.repeat(80)));
const completed: AgentName[] = [];
const failed: Array<{ agent: AgentName; error: string }> = [];
results.forEach((result, index) => {
const agentName = activeAgents[index]!;
const agentDisplay = agentName.padEnd(22);
if (result.status === 'fulfilled') {
const data = result.value;
completed.push(agentName);
const vulnCount = data.validation?.vulnerabilityCount || 0;
const duration = formatDuration(data.timing || 0);
const cost = `$${(data.cost || 0).toFixed(4)}`;
console.log(
`${chalk.green(agentDisplay)} ${chalk.green('✓ Success')} ${vulnCount.toString().padStart(5)} ` +
`${data.attempts}/3 ${duration.padEnd(11)} ${cost}`
);
if (data.logFile) {
const relativePath = path.relative(process.cwd(), data.logFile);
console.log(chalk.gray(` └─ Detailed log: ${relativePath}`));
}
} else {
const reason = result.reason as { error?: Error; attempts?: number };
const error = reason.error || result.reason;
const errMsg = error instanceof Error ? error.message : String(error);
failed.push({ agent: agentName, error: errMsg });
const attempts = reason.attempts || 3;
console.log(
`${chalk.red(agentDisplay)} ${chalk.red('✗ Failed ')} - ` +
`${attempts}/3 - -`
);
console.log(chalk.gray(` └─ ${errMsg.substring(0, 60)}...`));
}
});
console.log(chalk.gray('─'.repeat(80)));
console.log(chalk.cyan(`Summary: ${completed.length}/${activeAgents.length} succeeded in ${formatDuration(totalDuration)}`));
return { completed, failed };
};
// Run exploitation agents in parallel
const runParallelExploit = async (
session: Session,
pipelineTestingMode: boolean,
runClaudePromptWithRetry: RunClaudePromptWithRetry,
loadPrompt: LoadPrompt
): Promise<ParallelResult> => {
const exploitAgents: AgentName[] = ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit'];
// Get fresh session data
const freshSession = await getSession(session.id);
if (!freshSession) {
throw new PentestError(`Session ${session.id} not found`, 'validation', false);
}
// Load validation module
const { safeValidateQueueAndDeliverable } = await import('./queue-validation.js');
// Check eligibility
const eligibilityChecks = await Promise.all(
exploitAgents.map(async (agentName) => {
const vulnAgentName = agentName.replace('-exploit', '-vuln') as AgentName;
if (!freshSession.completedAgents.includes(vulnAgentName)) {
return { agentName, eligible: false };
}
const vulnType = vulnAgentName.replace('-vuln', '') as 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz';
const validation = await safeValidateQueueAndDeliverable(vulnType, freshSession.targetRepo);
if (!validation.success || !validation.data?.shouldExploit) {
console.log(chalk.gray(`⏭️ Skipping ${agentName} (no vulnerabilities found in ${vulnAgentName})`));
return { agentName, eligible: false };
}
console.log(chalk.blue(`${agentName} eligible (${validation.data.vulnerabilityCount} vulnerabilities from ${vulnAgentName})`));
return { agentName, eligible: true };
})
);
const eligibleAgents = eligibilityChecks
.filter(check => check.eligible)
.map(check => check.agentName);
const activeAgents = eligibleAgents.filter(agent => !freshSession.completedAgents.includes(agent));
if (activeAgents.length === 0) {
if (eligibleAgents.length === 0) {
console.log(chalk.gray('⏭️ No exploitation agents eligible (no vulnerabilities found)'));
} else {
console.log(chalk.gray('⏭️ All eligible exploitation agents already completed'));
}
return { completed: eligibleAgents, failed: [] };
}
console.log(chalk.cyan(`\n🎯 Starting ${activeAgents.length} exploitation specialists in parallel...`));
console.log(chalk.gray(' Specialists: ' + activeAgents.join(', ')));
console.log();
const startTime = Date.now();
const results = await Promise.allSettled(
activeAgents.map(async (agentName, index) => {
await new Promise(resolve => setTimeout(resolve, index * 2000));
let lastError: Error | undefined;
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
attempts++;
try {
const result = await runSingleAgent(agentName, freshSession, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, false, true);
return { ...result, attempts };
} catch (error) {
lastError = error as Error;
if (attempts < maxAttempts) {
console.log(chalk.yellow(`⚠️ ${agentName} failed attempt ${attempts}/${maxAttempts}, retrying...`));
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
throw { agentName, error: lastError, attempts };
})
);
const totalDuration = Date.now() - startTime;
console.log(chalk.cyan('\n🎯 Exploitation Results'));
console.log(chalk.gray('─'.repeat(80)));
console.log(chalk.bold('Agent Status Result Attempt Duration Cost'));
console.log(chalk.gray('─'.repeat(80)));
const completed: AgentName[] = [];
const failed: Array<{ agent: AgentName; error: string }> = [];
results.forEach((result, index) => {
const agentName = activeAgents[index]!;
const agentDisplay = agentName.padEnd(22);
if (result.status === 'fulfilled') {
const data = result.value;
completed.push(agentName);
const exploitResult = 'Success';
const duration = formatDuration(data.timing || 0);
const cost = `$${(data.cost || 0).toFixed(4)}`;
console.log(
`${chalk.green(agentDisplay)} ${chalk.green('✓ Success')} ${exploitResult.padEnd(6)} ` +
`${data.attempts}/3 ${duration.padEnd(11)} ${cost}`
);
if (data.logFile) {
const relativePath = path.relative(process.cwd(), data.logFile);
console.log(chalk.gray(` └─ Detailed log: ${relativePath}`));
}
} else {
const reason = result.reason as { error?: Error; attempts?: number };
const error = reason.error || result.reason;
const errMsg = error instanceof Error ? error.message : String(error);
failed.push({ agent: agentName, error: errMsg });
const attempts = reason.attempts || 3;
console.log(
`${chalk.red(agentDisplay)} ${chalk.red('✗ Failed ')} - ` +
`${attempts}/3 - -`
);
console.log(chalk.gray(` └─ ${errMsg.substring(0, 60)}...`));
}
});
console.log(chalk.gray('─'.repeat(80)));
console.log(chalk.cyan(`Summary: ${completed.length}/${activeAgents.length} succeeded in ${formatDuration(totalDuration)}`));
return { completed, failed };
};
// Run all agents in a phase
export const runPhase = async (
phaseName: string,
session: Session,
pipelineTestingMode: boolean,
runClaudePromptWithRetry: RunClaudePromptWithRetry,
loadPrompt: LoadPrompt
): Promise<void> => {
console.log(chalk.cyan(`\n📋 Running phase: ${phaseName} (parallel execution)`));
if (phaseName === 'vulnerability-analysis') {
console.log(chalk.cyan('🚀 Using parallel execution for 5x faster vulnerability analysis'));
const results = await runParallelVuln(session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
if (results.failed.length > 0) {
console.log(chalk.yellow(`⚠️ ${results.failed.length} agents failed, but phase continues`));
results.failed.forEach(failure => {
console.log(chalk.red(` - ${failure.agent}: ${failure.error}`));
});
}
console.log(chalk.green(`✅ Phase '${phaseName}' completed: ${results.completed.length} succeeded, ${results.failed.length} failed`));
return;
}
if (phaseName === 'exploitation') {
console.log(chalk.cyan('🎯 Using parallel execution for 5x faster exploitation'));
const results = await runParallelExploit(session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
if (results.failed.length > 0) {
console.log(chalk.yellow(`⚠️ ${results.failed.length} agents failed, but phase continues`));
results.failed.forEach(failure => {
console.log(chalk.red(` - ${failure.agent}: ${failure.error}`));
});
}
console.log(chalk.green(`✅ Phase '${phaseName}' completed: ${results.completed.length} succeeded, ${results.failed.length} failed`));
return;
}
// For other phases, run single agent
const agents = validatePhase(phaseName);
if (agents.length === 1) {
const agent = agents[0]!;
if (session.completedAgents.includes(agent.name)) {
console.log(chalk.gray(`⏭️ Agent '${agent.name}' already completed, skipping`));
return;
}
await runSingleAgent(agent.name, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
console.log(chalk.green(`✅ Phase '${phaseName}' completed successfully`));
} else {
throw new PentestError(`Phase '${phaseName}' has multiple agents but no parallel execution defined`, 'validation', false);
}
};
// Rollback to specific agent checkpoint
export const rollbackTo = async (targetAgent: string, session: Session): Promise<void> => {
console.log(chalk.yellow(`🔄 Rolling back to agent: ${targetAgent}`));
await validateTargetRepo(session.targetRepo);
validateAgent(targetAgent);
const agentName = targetAgent as AgentName;
if (!session.checkpoints[agentName]) {
throw new PentestError(
`No checkpoint found for agent '${targetAgent}' in session history`,
'validation',
false,
{ targetAgent, availableCheckpoints: Object.keys(session.checkpoints) }
);
}
const commitHash = session.checkpoints[agentName]!;
await rollbackGitToCommit(session.targetRepo, commitHash);
await rollbackToAgent(session.id, targetAgent);
// Mark rolled-back agents in audit system
try {
const { AuditSession } = await import('./audit/index.js');
const sessionMetadata: SessionMetadata = {
id: session.id,
webUrl: session.webUrl,
repoPath: session.repoPath,
...(session.outputPath && { outputPath: session.outputPath })
};
const auditSession = new AuditSession(sessionMetadata);
await auditSession.initialize();
const targetOrder = AGENTS[agentName]!.order;
const rolledBackAgents = Object.values(AGENTS)
.filter(agent => agent.order > targetOrder)
.map(agent => agent.name);
if (rolledBackAgents.length > 0) {
await auditSession.markMultipleRolledBack(rolledBackAgents);
console.log(chalk.gray(` Marked ${rolledBackAgents.length} agents as rolled-back in audit logs`));
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
console.log(chalk.yellow(` ⚠️ Failed to update audit logs: ${errMsg}`));
}
console.log(chalk.green(`✅ Successfully rolled back to agent '${targetAgent}'`));
};
// Rerun specific agent
export const rerunAgent = async (
agentName: string,
session: Session,
pipelineTestingMode: boolean,
runClaudePromptWithRetry: RunClaudePromptWithRetry,
loadPrompt: LoadPrompt
): Promise<void> => {
console.log(chalk.cyan(`🔁 Rerunning agent: ${agentName}`));
const agent = validateAgent(agentName);
// Find previous agent checkpoint
let rollbackTarget: AgentName | null = null;
if (agent.prerequisites.length > 0) {
const completedPrereqs = agent.prerequisites.filter(prereq =>
session.completedAgents.includes(prereq)
);
if (completedPrereqs.length > 0) {
rollbackTarget = completedPrereqs.reduce((latest, current) =>
AGENTS[current]!.order > AGENTS[latest]!.order ? current : latest
);
}
}
if (rollbackTarget) {
console.log(chalk.blue(`📍 Rolling back to prerequisite: ${rollbackTarget}`));
await rollbackTo(rollbackTarget, session);
} else if (agent.name === 'pre-recon') {
console.log(chalk.blue(`📍 Rolling back to initial repository state`));
try {
const initialCommit = await executeGitCommandWithRetry(['git', 'log', '--reverse', '--format=%H'], session.targetRepo, 'finding initial commit');
const firstCommit = initialCommit.stdout.trim().split('\n')[0];
if (firstCommit) {
await rollbackGitToCommit(session.targetRepo, firstCommit);
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
console.log(chalk.yellow(`⚠️ Could not find initial commit, using HEAD: ${errMsg}`));
}
}
await runSingleAgent(agent.name, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, true);
console.log(chalk.green(`✅ Agent '${agentName}' rerun completed successfully`));
};
// Run all remaining agents
export const runAll = async (
session: Session,
pipelineTestingMode: boolean,
runClaudePromptWithRetry: RunClaudePromptWithRetry,
loadPrompt: LoadPrompt
): Promise<void> => {
const allAgentNames = Object.keys(AGENTS) as AgentName[];
console.log(chalk.cyan(`\n🚀 Running all remaining agents to completion`));
console.log(chalk.gray(`Current progress: ${session.completedAgents.length}/${allAgentNames.length} agents completed`));
const remainingAgents = allAgentNames.filter(agentName =>
!session.completedAgents.includes(agentName)
);
if (remainingAgents.length === 0) {
console.log(chalk.green('✅ All agents already completed!'));
return;
}
console.log(chalk.blue(`📋 Remaining agents: ${remainingAgents.join(', ')}`));
console.log();
for (const agentName of remainingAgents) {
await runSingleAgent(agentName, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
}
console.log(chalk.green(`\n🎉 All agents completed successfully! Session marked as completed.`));
};
// Helper for time ago calculation
const getTimeAgo = (timestamp: string): string => {
const now = new Date();
const past = new Date(timestamp);
const diffMs = now.getTime() - past.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else {
return `${diffDays}d ago`;
}
};
// Display session status
export const displayStatus = async (session: Session): Promise<void> => {
const status = getSessionStatus(session);
const timeAgo = getTimeAgo(session.lastActivity);
console.log(chalk.cyan(`Session: ${new URL(session.webUrl).hostname} + ${path.basename(session.repoPath)}`));
console.log(chalk.gray(`Session ID: ${session.id}`));
console.log(chalk.gray(`Source Directory: ${session.targetRepo}`));
// Check if final deliverable exists
if (session.targetRepo) {
const finalReportPath = path.join(session.targetRepo, 'deliverables', 'comprehensive_security_assessment_report.md');
try {
if (await fs.pathExists(finalReportPath)) {
console.log(chalk.gray(`Final Deliverable Available: ${finalReportPath}`));
}
} catch {
// Silently ignore
}
}
const statusColor = status.status === 'completed' ? chalk.green : status.status === 'failed' ? chalk.red : chalk.blue;
console.log(statusColor(`Status: ${status.status} (${status.completedCount}/${status.totalAgents} agents completed)`));
console.log(chalk.gray(`Last Activity: ${timeAgo}`));
if (session.configFile) {
console.log(chalk.gray(`Config: ${session.configFile}`));
}
console.log();
// Display agent status
const agentList = Object.values(AGENTS).sort((a, b) => a.order - b.order);
for (const agent of agentList) {
let statusIcon: string, statusText: string, statusColorFn: ChalkInstance;
if (session.completedAgents.includes(agent.name)) {
statusIcon = '✅';
statusText = `completed ${getTimeAgo(session.lastActivity)}`;
statusColorFn = chalk.green;
} else if (session.failedAgents.includes(agent.name)) {
statusIcon = '❌';
statusText = `failed ${getTimeAgo(session.lastActivity)}`;
statusColorFn = chalk.red;
} else {
statusIcon = '⏸️';
statusText = 'pending';
statusColorFn = chalk.gray;
}
const displayName = agent.name.replace(/-/g, ' ');
console.log(`${statusIcon} ${statusColorFn(displayName.padEnd(20))} (${statusText})`);
}
// Show next action
const nextAgent = getNextAgent(session);
if (nextAgent) {
console.log(chalk.cyan(`\nNext: Run --run-agent ${nextAgent.name}`));
} else if (status.failedCount > 0) {
const failedAgent = session.failedAgents[0];
console.log(chalk.yellow(`\nNext: Fix ${failedAgent} failure or run --rerun ${failedAgent}`));
} else if (status.status === 'completed') {
console.log(chalk.green('\nAll agents completed successfully! 🎉'));
}
};
// List all available agents
export const listAgents = (): void => {
console.log(chalk.cyan('Available Agents:'));
const phaseNames = Object.keys(PHASES) as PhaseName[];
phaseNames.forEach((phaseName, phaseIndex) => {
const phaseAgents = PHASES[phaseName];
const phaseDisplayName = phaseName.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
console.log(chalk.yellow(`\nPhase ${phaseIndex + 1} - ${phaseDisplayName}:`));
phaseAgents.forEach(agentName => {
const agent = AGENTS[agentName]!;
console.log(chalk.white(` ${agent.name.padEnd(18)} ${agent.displayName}`));
});
});
};

View File

@@ -1,180 +0,0 @@
// 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 {
selectSession, deleteSession, deleteAllSessions,
validateAgent, validatePhase, reconcileSession, getSession
} from '../session-manager.js';
import type { Session } from '../session-manager.js';
import {
runPhase, runAll, rollbackTo, rerunAgent, displayStatus, listAgents
} from '../checkpoint-manager.js';
import type { AgentResult } from '../checkpoint-manager.js';
import { logError, PentestError } from '../error-handling.js';
import { promptConfirmation } from './prompts.js';
import type { ChalkInstance } from 'chalk';
import type { SessionMetadata } from '../audit/utils.js';
import type { DistributedConfig } from '../types/config.js';
// Types for callback functions
type RunClaudePromptWithRetry = (
prompt: string,
sourceDir: string,
allowedTools: string,
context: string,
description: string,
agentName: string | null,
colorFn: ChalkInstance,
sessionMetadata: SessionMetadata | null
) => Promise<AgentResult>;
type LoadPrompt = (
promptName: string,
variables: { webUrl: string; repoPath: string },
config: DistributedConfig | null,
pipelineTestingMode: boolean
) => Promise<string>;
// Developer command handlers
export async function handleDeveloperCommand(
command: string,
args: string[],
pipelineTestingMode: boolean,
runClaudePromptWithRetry: RunClaudePromptWithRetry,
loadPrompt: LoadPrompt
): Promise<void> {
try {
let session: Session | null;
// Commands that don't require session selection
if (command === '--list-agents') {
listAgents();
return;
}
if (command === '--cleanup') {
// Handle cleanup without needing session selection first
if (args[0]) {
// Cleanup specific session by ID
const sessionId = args[0];
const deletedSession = await deleteSession(sessionId);
console.log(chalk.green(`✅ Deleted session ${sessionId} (${new URL(deletedSession.webUrl).hostname})`));
} else {
// Cleanup all sessions - require confirmation
const confirmed = await promptConfirmation(chalk.yellow('⚠️ This will delete all pentest sessions. Are you sure? (y/N):'));
if (confirmed) {
const deleted = await deleteAllSessions();
if (deleted) {
console.log(chalk.green('✅ All sessions deleted'));
} else {
console.log(chalk.yellow('⚠️ No sessions found to delete'));
}
} else {
console.log(chalk.gray('Cleanup cancelled'));
}
}
return;
}
// Early validation for commands with agent names (before session selection)
if (command === '--run-phase') {
if (!args[0]) {
console.log(chalk.red('❌ --run-phase requires a phase name'));
console.log(chalk.gray('Usage: shannon --run-phase <phase-name>'));
process.exit(1);
}
validatePhase(args[0]); // This will throw PentestError if invalid
}
if (command === '--rollback-to' || command === '--rerun') {
if (!args[0]) {
console.log(chalk.red(`${command} requires an agent name`));
console.log(chalk.gray(`Usage: shannon ${command} <agent-name>`));
process.exit(1);
}
validateAgent(args[0]); // This will throw PentestError if invalid
}
// Get session for other commands
try {
session = await selectSession();
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
console.log(chalk.red(`${errMsg}`));
process.exit(1);
}
// Self-healing: Reconcile session with audit logs before executing command
// This ensures Shannon store is consistent with audit data, even after crash recovery
try {
const reconcileReport = await reconcileSession(session.id);
if (reconcileReport.promotions.length > 0) {
console.log(chalk.blue(`🔄 Reconciled: Added ${reconcileReport.promotions.length} completed agents from audit logs`));
}
if (reconcileReport.demotions.length > 0) {
console.log(chalk.yellow(`🔄 Reconciled: Removed ${reconcileReport.demotions.length} rolled-back agents`));
}
if (reconcileReport.failures.length > 0) {
console.log(chalk.yellow(`🔄 Reconciled: Marked ${reconcileReport.failures.length} failed agents`));
}
// Reload session after reconciliation to get fresh state
session = await getSession(session.id);
} catch (error) {
// Reconciliation failure is non-critical, but log warning
const errMsg = error instanceof Error ? error.message : String(error);
console.log(chalk.yellow(`⚠️ Failed to reconcile session with audit logs: ${errMsg}`));
}
if (!session) {
console.log(chalk.red('❌ Session not found after reconciliation'));
process.exit(1);
}
switch (command) {
case '--run-phase':
await runPhase(args[0]!, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
break;
case '--run-all':
await runAll(session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
break;
case '--rollback-to':
await rollbackTo(args[0]!, session);
break;
case '--rerun':
await rerunAgent(args[0]!, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
break;
case '--status':
await displayStatus(session);
break;
default:
console.log(chalk.red(`❌ Unknown developer command: ${command}`));
console.log(chalk.gray('Use --help to see available commands'));
process.exit(1);
}
} catch (error) {
if (error instanceof PentestError) {
await logError(error, `Developer command ${command}`);
console.log(chalk.red.bold(`\n🚨 Command failed: ${error.message}`));
} else {
const errMsg = error instanceof Error ? error.message : String(error);
console.log(chalk.red.bold(`\n🚨 Unexpected error: ${errMsg}`));
if (process.env.DEBUG && error instanceof Error) {
console.log(chalk.gray(error.stack));
}
}
process.exit(1);
}
}

View File

@@ -1,60 +0,0 @@
// 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 { createInterface } from 'readline';
import { PentestError } from '../error-handling.js';
/**
* Prompt user for yes/no confirmation
*/
export async function promptConfirmation(message: string): Promise<boolean> {
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
readline.question(message + ' ', (answer) => {
readline.close();
const confirmed = answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
resolve(confirmed);
});
});
}
/**
* Prompt user to select from numbered list
*/
export async function promptSelection<T>(message: string, items: T[]): Promise<T> {
if (!items || items.length === 0) {
throw new PentestError('No items available for selection', 'validation', false);
}
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve, reject) => {
readline.question(message + ' ', (answer) => {
readline.close();
const choice = parseInt(answer);
if (isNaN(choice) || choice < 1 || choice > items.length) {
reject(
new PentestError(
`Invalid selection. Please enter a number between 1 and ${items.length}`,
'validation',
false,
{ choice: answer }
)
);
} else {
resolve(items[choice - 1]!);
}
});
});
}

View File

@@ -12,22 +12,8 @@ export function showHelp(): void {
console.log(chalk.cyan.bold('AI Penetration Testing Agent'));
console.log(chalk.gray('Automated security assessment tool\n'));
console.log(chalk.yellow.bold('NORMAL MODE (Creates Sessions):'));
console.log(
' shannon <WEB_URL> <REPO_PATH> [--config config.yaml] [--pipeline-testing]'
);
console.log(
' shannon <WEB_URL> <REPO_PATH> --setup-only # Setup local repo and create session only\n'
);
console.log(chalk.yellow.bold('DEVELOPER MODE (Operates on Existing Sessions):'));
console.log(' shannon --run-phase <phase-name> [--pipeline-testing]');
console.log(' shannon --run-all [--pipeline-testing]');
console.log(' shannon --rollback-to <agent-name>');
console.log(' shannon --rerun <agent-name> [--pipeline-testing]');
console.log(' shannon --status');
console.log(' shannon --list-agents');
console.log(' shannon --cleanup [session-id] # Delete sessions\n');
console.log(chalk.yellow.bold('USAGE:'));
console.log(' shannon <WEB_URL> <REPO_PATH> [--config config.yaml] [--output /path/to/reports]\n');
console.log(chalk.yellow.bold('OPTIONS:'));
console.log(
@@ -40,42 +26,20 @@ export function showHelp(): void {
' --pipeline-testing Use minimal prompts for fast pipeline testing (creates minimal deliverables)'
);
console.log(
' --disable-loader Disable the animated progress loader (useful when logs interfere with spinner)\n'
' --disable-loader Disable the animated progress loader (useful when logs interfere with spinner)'
);
console.log(chalk.yellow.bold('DEVELOPER COMMANDS:'));
console.log(
' --run-phase Run all agents in a phase (parallel execution for 5x speedup)'
);
console.log(' --run-all Run all remaining agents to completion (parallel execution)');
console.log(' --rollback-to Rollback git workspace to agent checkpoint');
console.log(' --rerun Rollback and rerun specific agent');
console.log(' --status Show current session status and progress');
console.log(' --list-agents List all available agents and phases');
console.log(' --cleanup Delete all sessions or specific session by ID\n');
console.log(' --help Show this help message\n');
console.log(chalk.yellow.bold('EXAMPLES:'));
console.log(' # Normal mode - create new session');
console.log(' shannon "https://example.com" "/path/to/local/repo"');
console.log(' shannon "https://example.com" "/path/to/local/repo" --config auth.yaml');
console.log(' shannon "https://example.com" "/path/to/local/repo" --output /path/to/reports');
console.log(
' shannon "https://example.com" "/path/to/local/repo" --setup-only # Setup only\n'
);
console.log(' # Developer mode - operate on existing session');
console.log(' shannon --status # Show session status');
console.log(' shannon --run-phase exploitation # Run entire phase');
console.log(' shannon --run-all # Run all remaining agents');
console.log(' shannon --rerun xss-vuln # Fix and rerun failed agent');
console.log(' shannon --cleanup # Delete all sessions');
console.log(' shannon --cleanup <session-id> # Delete specific session\n');
console.log(' shannon "https://example.com" "/path/to/local/repo" --pipeline-testing\n');
console.log(chalk.yellow.bold('REQUIREMENTS:'));
console.log(' • WEB_URL must start with http:// or https://');
console.log(' • REPO_PATH must be an accessible local directory');
console.log(' • Only test systems you own or have permission to test');
console.log(' • Developer mode requires existing pentest session\n');
console.log(' • Only test systems you own or have permission to test\n');
console.log(chalk.yellow.bold('ENVIRONMENT VARIABLES:'));
console.log(' PENTEST_MAX_RETRIES Number of retries for AI agents (default: 3)');

View File

@@ -6,7 +6,7 @@
import { $, fs, path } from 'zx';
import chalk from 'chalk';
import { Timer, timingResults } from '../utils/metrics.js';
import { Timer } from '../utils/metrics.js';
import { formatDuration } from '../audit/utils.js';
import { handleToolError, PentestError } from '../error-handling.js';
import { AGENTS } from '../session-manager.js';
@@ -14,7 +14,14 @@ import { runClaudePromptWithRetry } from '../ai/claude-executor.js';
import { loadPrompt } from '../prompts/prompt-manager.js';
import type { ToolAvailability } from '../tool-checker.js';
import type { DistributedConfig } from '../types/config.js';
import type { AgentResult } from '../checkpoint-manager.js';
interface AgentResult {
success: boolean;
duration: number;
cost?: number;
error?: string;
retryable?: boolean;
}
type ToolName = 'nmap' | 'subfinder' | 'whatweb' | 'schemathesis';
type ToolStatus = 'success' | 'skipped' | 'error';
@@ -61,7 +68,6 @@ async function runTerminalScan(tool: ToolName, target: string, sourceDir: string
const nmapHostname = new URL(target).hostname;
result = await $({ silent: true, stdio: ['ignore', 'pipe', 'ignore'] })`nmap -sV -sC ${nmapHostname}`;
const duration = timer.stop();
timingResults.commands[tool] = duration;
console.log(chalk.green(`${tool} completed in ${formatDuration(duration)}`));
return { tool: 'nmap', output: result.stdout, status: 'success', duration };
}
@@ -70,7 +76,6 @@ async function runTerminalScan(tool: ToolName, target: string, sourceDir: string
const hostname = new URL(target).hostname;
result = await $({ silent: true, stdio: ['ignore', 'pipe', 'ignore'] })`subfinder -d ${hostname}`;
const subfinderDuration = timer.stop();
timingResults.commands[tool] = subfinderDuration;
console.log(chalk.green(`${tool} completed in ${formatDuration(subfinderDuration)}`));
return { tool: 'subfinder', output: result.stdout, status: 'success', duration: subfinderDuration };
}
@@ -80,7 +85,6 @@ async function runTerminalScan(tool: ToolName, target: string, sourceDir: string
console.log(chalk.gray(` Command: ${command}`));
result = await $({ silent: true, stdio: ['ignore', 'pipe', 'ignore'] })`whatweb --open-timeout 30 --read-timeout 60 ${target}`;
const whatwebDuration = timer.stop();
timingResults.commands[tool] = whatwebDuration;
console.log(chalk.green(`${tool} completed in ${formatDuration(whatwebDuration)}`));
return { tool: 'whatweb', output: result.stdout, status: 'success', duration: whatwebDuration };
}
@@ -107,7 +111,6 @@ async function runTerminalScan(tool: ToolName, target: string, sourceDir: string
}
const schemaDuration = timer.stop();
timingResults.commands[tool] = schemaDuration;
console.log(chalk.green(`${tool} completed in ${formatDuration(schemaDuration)}`));
return { tool: 'schemathesis', output: allResults.join('\n\n'), status: 'success', duration: schemaDuration };
} else {
@@ -124,7 +127,6 @@ async function runTerminalScan(tool: ToolName, target: string, sourceDir: string
}
} catch (error) {
const duration = timer.stop();
timingResults.commands[tool] = duration;
console.log(chalk.red(`${tool} failed in ${formatDuration(duration)}`));
return handleToolError(tool, error as Error & { code?: string }) as TerminalScanResult;
}

View File

@@ -4,809 +4,112 @@
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation.
import { fs, path } from 'zx';
import chalk from 'chalk';
import crypto from 'crypto';
import { PentestError } from './error-handling.js';
import { SessionMutex } from './utils/concurrency.js';
import { promptSelection } from './cli/prompts.js';
import type { AgentName, PhaseName } from './types/index.js';
import type { SessionMetadata } from './audit/utils.js';
// Audit data types for reconciliation
interface AuditAgentData {
status: 'in-progress' | 'success' | 'failed' | 'rolled-back';
checkpoint?: string;
}
interface AuditMetricsData {
metrics: {
agents: Record<string, AuditAgentData>;
};
}
import { path } from 'zx';
import type { AgentName } from './types/index.js';
// Agent definition interface
export interface AgentDefinition {
name: AgentName;
displayName: string;
phase: PhaseName;
order: number;
prerequisites: AgentName[];
}
// Session interface
export interface Session {
id: string;
webUrl: string;
repoPath: string;
configFile: string | null;
targetRepo: string;
outputPath: string | null;
status: 'in-progress' | 'completed' | 'failed';
completedAgents: AgentName[];
failedAgents: AgentName[];
checkpoints: Record<AgentName, string>;
createdAt: string;
lastActivity: string;
}
// Agent definitions according to PRD
export const AGENTS: Readonly<Record<AgentName, AgentDefinition>> = Object.freeze({
'pre-recon': {
name: 'pre-recon',
displayName: 'Pre-recon agent',
prerequisites: []
},
'recon': {
name: 'recon',
displayName: 'Recon agent',
prerequisites: ['pre-recon']
},
'injection-vuln': {
name: 'injection-vuln',
displayName: 'Injection vuln agent',
prerequisites: ['recon']
},
'xss-vuln': {
name: 'xss-vuln',
displayName: 'XSS vuln agent',
prerequisites: ['recon']
},
'auth-vuln': {
name: 'auth-vuln',
displayName: 'Auth vuln agent',
prerequisites: ['recon']
},
'ssrf-vuln': {
name: 'ssrf-vuln',
displayName: 'SSRF vuln agent',
prerequisites: ['recon']
},
'authz-vuln': {
name: 'authz-vuln',
displayName: 'Authz vuln agent',
prerequisites: ['recon']
},
'injection-exploit': {
name: 'injection-exploit',
displayName: 'Injection exploit agent',
prerequisites: ['injection-vuln']
},
'xss-exploit': {
name: 'xss-exploit',
displayName: 'XSS exploit agent',
prerequisites: ['xss-vuln']
},
'auth-exploit': {
name: 'auth-exploit',
displayName: 'Auth exploit agent',
prerequisites: ['auth-vuln']
},
'ssrf-exploit': {
name: 'ssrf-exploit',
displayName: 'SSRF exploit agent',
prerequisites: ['ssrf-vuln']
},
'authz-exploit': {
name: 'authz-exploit',
displayName: 'Authz exploit agent',
prerequisites: ['authz-vuln']
},
'report': {
name: 'report',
displayName: 'Report agent',
prerequisites: ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit']
}
});
// Session store interface
interface SessionStore {
sessions: Record<string, Session>;
}
// Agent execution order
export const AGENT_ORDER: readonly AgentName[] = Object.freeze([
'pre-recon',
'recon',
'injection-vuln',
'xss-vuln',
'auth-vuln',
'ssrf-vuln',
'authz-vuln',
'injection-exploit',
'xss-exploit',
'auth-exploit',
'ssrf-exploit',
'authz-exploit',
'report'
] as const);
// Session status result
export interface SessionStatusResult {
status: 'in-progress' | 'completed' | 'failed';
completedCount: number;
totalAgents: number;
failedCount: number;
completionPercentage: number;
}
// Parallel execution groups
export const getParallelGroups = (): Readonly<{ vuln: AgentName[]; exploit: AgentName[] }> => Object.freeze({
vuln: ['injection-vuln', 'xss-vuln', 'auth-vuln', 'ssrf-vuln', 'authz-vuln'],
exploit: ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit']
});
// Reconciliation report
interface ReconciliationReport {
promotions: string[];
demotions: string[];
failures: string[];
}
// Generate a session-based log folder path
// NEW FORMAT: {hostname}_{sessionId} (no hash, full UUID for consistency with audit system)
// Generate a session-based log folder path (used by claude-executor.ts)
export const generateSessionLogPath = (webUrl: string, sessionId: string): string => {
const hostname = new URL(webUrl).hostname.replace(/[^a-zA-Z0-9-]/g, '-');
const sessionFolderName = `${hostname}_${sessionId}`;
return path.join(process.cwd(), 'agent-logs', sessionFolderName);
};
const sessionMutex = new SessionMutex();
// Agent definitions according to PRD
export const AGENTS: Readonly<Record<AgentName, AgentDefinition>> = Object.freeze({
// Phase 1 - Pre-reconnaissance
'pre-recon': {
name: 'pre-recon',
displayName: 'Pre-recon agent',
phase: 'pre-reconnaissance',
order: 1,
prerequisites: []
},
// Phase 2 - Reconnaissance
'recon': {
name: 'recon',
displayName: 'Recon agent',
phase: 'reconnaissance',
order: 2,
prerequisites: ['pre-recon']
},
// Phase 3 - Vulnerability Analysis
'injection-vuln': {
name: 'injection-vuln',
displayName: 'Injection vuln agent',
phase: 'vulnerability-analysis',
order: 3,
prerequisites: ['recon']
},
'xss-vuln': {
name: 'xss-vuln',
displayName: 'XSS vuln agent',
phase: 'vulnerability-analysis',
order: 4,
prerequisites: ['recon']
},
'auth-vuln': {
name: 'auth-vuln',
displayName: 'Auth vuln agent',
phase: 'vulnerability-analysis',
order: 5,
prerequisites: ['recon']
},
'ssrf-vuln': {
name: 'ssrf-vuln',
displayName: 'SSRF vuln agent',
phase: 'vulnerability-analysis',
order: 6,
prerequisites: ['recon']
},
'authz-vuln': {
name: 'authz-vuln',
displayName: 'Authz vuln agent',
phase: 'vulnerability-analysis',
order: 7,
prerequisites: ['recon']
},
// Phase 4 - Exploitation
'injection-exploit': {
name: 'injection-exploit',
displayName: 'Injection exploit agent',
phase: 'exploitation',
order: 8,
prerequisites: ['injection-vuln']
},
'xss-exploit': {
name: 'xss-exploit',
displayName: 'XSS exploit agent',
phase: 'exploitation',
order: 9,
prerequisites: ['xss-vuln']
},
'auth-exploit': {
name: 'auth-exploit',
displayName: 'Auth exploit agent',
phase: 'exploitation',
order: 10,
prerequisites: ['auth-vuln']
},
'ssrf-exploit': {
name: 'ssrf-exploit',
displayName: 'SSRF exploit agent',
phase: 'exploitation',
order: 11,
prerequisites: ['ssrf-vuln']
},
'authz-exploit': {
name: 'authz-exploit',
displayName: 'Authz exploit agent',
phase: 'exploitation',
order: 12,
prerequisites: ['authz-vuln']
},
// Phase 5 - Reporting
'report': {
name: 'report',
displayName: 'Report agent',
phase: 'reporting',
order: 13,
prerequisites: ['authz-exploit']
}
});
// Phase definitions
export const PHASES: Readonly<Record<PhaseName, readonly AgentName[]>> = Object.freeze({
'pre-reconnaissance': ['pre-recon'],
'reconnaissance': ['recon'],
'vulnerability-analysis': ['injection-vuln', 'xss-vuln', 'auth-vuln', 'ssrf-vuln', 'authz-vuln'],
'exploitation': ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit'],
'reporting': ['report']
});
// Session store file path
const STORE_FILE = path.join(process.cwd(), '.shannon-store.json');
// Load sessions from store file
const loadSessions = async (): Promise<SessionStore> => {
try {
if (!await fs.pathExists(STORE_FILE)) {
return { sessions: {} };
}
const content = await fs.readFile(STORE_FILE, 'utf8');
const store = JSON.parse(content) as unknown;
// Validate store structure
if (!store || typeof store !== 'object' || !('sessions' in store)) {
console.log(chalk.yellow('⚠️ Invalid session store format, creating new store'));
return { sessions: {} };
}
return store as SessionStore;
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
console.log(chalk.yellow(`⚠️ Failed to load session store: ${errMsg}, creating new store`));
return { sessions: {} };
}
};
// Save sessions to store file atomically
const saveSessions = async (store: SessionStore): Promise<void> => {
try {
const tempFile = `${STORE_FILE}.tmp`;
await fs.writeJSON(tempFile, store, { spaces: 2 });
await fs.move(tempFile, STORE_FILE, { overwrite: true });
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new PentestError(
`Failed to save session store: ${errMsg}`,
'filesystem',
false,
{ storeFile: STORE_FILE, originalError: errMsg }
);
}
};
// Find existing session for the same web URL and repository path
const findExistingSession = async (webUrl: string, targetRepo: string): Promise<Session | undefined> => {
const store = await loadSessions();
const sessions = Object.values(store.sessions);
// Normalize paths for comparison
const normalizedTargetRepo = path.resolve(targetRepo);
// Look for existing session with same webUrl and targetRepo
const existingSession = sessions.find(session => {
const normalizedSessionRepo = path.resolve(session.targetRepo || session.repoPath);
return session.webUrl === webUrl && normalizedSessionRepo === normalizedTargetRepo;
});
return existingSession;
};
// Generate session ID as unique UUID
const generateSessionId = (): string => {
// Always generate a unique UUID for each session
return crypto.randomUUID();
};
// Create new session or return existing one
export const createSession = async (
webUrl: string,
repoPath: string,
configFile: string | null = null,
targetRepo: string | null = null,
outputPath: string | null = null
): Promise<Session> => {
// Use targetRepo if provided, otherwise use repoPath
const resolvedTargetRepo = targetRepo || repoPath;
// Check for existing session first
const existingSession = await findExistingSession(webUrl, resolvedTargetRepo);
if (existingSession) {
// If session is not completed, reuse it
if (existingSession.status !== 'completed') {
console.log(chalk.blue(`📝 Reusing existing session: ${existingSession.id.substring(0, 8)}...`));
console.log(chalk.gray(` Progress: ${existingSession.completedAgents.length}/${Object.keys(AGENTS).length} agents completed`));
// Update last activity timestamp and outputPath if provided
await updateSession(existingSession.id, {
lastActivity: new Date().toISOString(),
...(outputPath && { outputPath })
});
return { ...existingSession, ...(outputPath && { outputPath }) };
}
// If completed, create a new session (allows re-running after completion)
console.log(chalk.gray(`Previous session was completed, creating new session...`));
}
const sessionId = generateSessionId();
// STANDARD: All sessions use 'id' field (NOT 'sessionId')
// This is the canonical session structure used throughout the codebase
const session: Session = {
id: sessionId,
webUrl,
repoPath,
configFile,
targetRepo: resolvedTargetRepo,
outputPath,
status: 'in-progress',
completedAgents: [],
failedAgents: [],
checkpoints: {} as Record<AgentName, string>,
createdAt: new Date().toISOString(),
lastActivity: new Date().toISOString()
};
const store = await loadSessions();
store.sessions[sessionId] = session;
await saveSessions(store);
return session;
};
// Get session by ID
export const getSession = async (sessionId: string): Promise<Session | null> => {
const store = await loadSessions();
return store.sessions[sessionId] || null;
};
// Update session
export const updateSession = async (
sessionId: string,
updates: Partial<Session>
): Promise<Session> => {
const store = await loadSessions();
if (!store.sessions[sessionId]) {
throw new PentestError(
`Session ${sessionId} not found`,
'validation',
false,
{ sessionId }
);
}
store.sessions[sessionId] = {
...store.sessions[sessionId]!,
...updates,
lastActivity: new Date().toISOString()
};
await saveSessions(store);
return store.sessions[sessionId]!;
};
// List all sessions
const listSessions = async (): Promise<Session[]> => {
const store = await loadSessions();
return Object.values(store.sessions);
};
// Interactive session selection
export const selectSession = async (): Promise<Session> => {
const sessions = await listSessions();
if (sessions.length === 0) {
throw new PentestError(
'No pentest sessions found. Run a normal pentest first to create a session.',
'validation',
false
);
}
if (sessions.length === 1) {
return sessions[0]!;
}
// Display session options
console.log(chalk.cyan('\nMultiple pentest sessions found:\n'));
sessions.forEach((session, index) => {
const completedCount = session.completedAgents.length;
const totalAgents = Object.keys(AGENTS).length;
const timeAgo = getTimeAgo(session.lastActivity);
// Use dynamic status calculation instead of stored status
const { status } = getSessionStatus(session);
const statusColor = status === 'completed' ? chalk.green : chalk.blue;
console.log(statusColor(`${index + 1}) ${new URL(session.webUrl).hostname} + ${path.basename(session.repoPath)} [${status}]`));
console.log(chalk.gray(` Last activity: ${timeAgo}, Completed: ${completedCount}/${totalAgents} agents`));
console.log(chalk.gray(` Session ID: ${session.id}`));
if (session.configFile) {
console.log(chalk.gray(` Config: ${session.configFile}`));
}
console.log(); // Empty line between sessions
});
// Get user selection
return await promptSelection(
chalk.cyan(`Select session (1-${sessions.length}):`),
sessions
);
};
// Validate agent name
export const validateAgent = (agentName: string): AgentDefinition => {
const agent = AGENTS[agentName as AgentName];
if (!agent) {
throw new PentestError(
`Agent '${agentName}' not recognized. Use --list-agents to see valid names.`,
'validation',
false,
{ agentName, validAgents: Object.keys(AGENTS) }
);
}
return agent;
};
// Validate agent range
export const validateAgentRange = (startAgent: string, endAgent: string): AgentDefinition[] => {
const start = validateAgent(startAgent);
const end = validateAgent(endAgent);
if (start.order >= end.order) {
throw new PentestError(
`End agent '${endAgent}' must come after start agent '${startAgent}' in sequence.`,
'validation',
false,
{ startAgent, endAgent, startOrder: start.order, endOrder: end.order }
);
}
// Get all agents in range
const agentList = Object.values(AGENTS)
.filter(agent => agent.order >= start.order && agent.order <= end.order)
.sort((a, b) => a.order - b.order);
return agentList;
};
// Validate phase name
export const validatePhase = (phaseName: string): AgentDefinition[] => {
const phase = PHASES[phaseName as PhaseName];
if (!phase) {
throw new PentestError(
`Phase '${phaseName}' not recognized. Valid phases: ${Object.keys(PHASES).join(', ')}`,
'validation',
false,
{ phaseName, validPhases: Object.keys(PHASES) }
);
}
return phase.map(agentName => AGENTS[agentName]!);
};
// Check prerequisites for an agent
export const checkPrerequisites = (session: Session, agentName: string): boolean => {
const agent = validateAgent(agentName);
const missingPrereqs = agent.prerequisites.filter(prereq =>
!session.completedAgents.includes(prereq)
);
if (missingPrereqs.length > 0) {
throw new PentestError(
`Cannot run '${agentName}': prerequisite agent(s) not completed: ${missingPrereqs.join(', ')}`,
'validation',
false,
{ agentName, missingPrerequisites: missingPrereqs, completedAgents: session.completedAgents }
);
}
return true;
};
// Get next suggested agent
export const getNextAgent = (session: Session): AgentDefinition | undefined => {
const completed = new Set(session.completedAgents);
// Find the next agent that hasn't been completed and has all prerequisites
const nextAgent = Object.values(AGENTS)
.sort((a, b) => a.order - b.order)
.find(agent => {
if (completed.has(agent.name)) return false; // Already completed
// Check if all prerequisites are completed
const prereqsMet = agent.prerequisites.every(prereq => completed.has(prereq));
return prereqsMet;
});
return nextAgent;
};
// Mark agent as completed with checkpoint
// NOTE: Timing, cost, and validation data now managed by AuditSession (audit-logs/session.json)
// Shannon store contains ONLY orchestration state (completedAgents, checkpoints)
export const markAgentCompleted = async (
sessionId: string,
agentName: string,
checkpointCommit: string
): Promise<Session> => {
// Use mutex to prevent race conditions during parallel agent execution
const unlock = await sessionMutex.lock(sessionId);
try {
// Get fresh session data under lock
const session = await getSession(sessionId);
if (!session) {
throw new PentestError(`Session ${sessionId} not found`, 'validation', false);
}
validateAgent(agentName);
const updates: Partial<Session> = {
completedAgents: [...new Set([...session.completedAgents, agentName as AgentName])],
failedAgents: session.failedAgents.filter(agent => agent !== agentName),
checkpoints: {
...session.checkpoints,
[agentName]: checkpointCommit
} as Record<AgentName, string>
};
// Check if all agents are now completed and update session status
const totalAgents = Object.keys(AGENTS).length;
if (updates.completedAgents!.length === totalAgents) {
updates.status = 'completed';
}
return await updateSession(sessionId, updates);
} finally {
// Always release the lock, even if an error occurs
unlock();
}
};
// Mark agent as failed
export const markAgentFailed = async (sessionId: string, agentName: string): Promise<Session> => {
const session = await getSession(sessionId);
if (!session) {
throw new PentestError(`Session ${sessionId} not found`, 'validation', false);
}
validateAgent(agentName);
const updates: Partial<Session> = {
failedAgents: [...new Set([...session.failedAgents, agentName as AgentName])],
completedAgents: session.completedAgents.filter(agent => agent !== agentName)
};
return await updateSession(sessionId, updates);
};
// Get time ago helper
const getTimeAgo = (timestamp: string): string => {
const now = new Date();
const past = new Date(timestamp);
const diffMs = now.getTime() - past.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else {
return `${diffDays}d ago`;
}
};
// Get session status summary
export const getSessionStatus = (session: Session): SessionStatusResult => {
const totalAgents = Object.keys(AGENTS).length;
const completedCount = session.completedAgents.length;
const failedCount = session.failedAgents.length;
let status: 'in-progress' | 'completed' | 'failed';
if (completedCount === totalAgents) {
status = 'completed';
} else if (failedCount > 0) {
status = 'failed';
} else {
status = 'in-progress';
}
return {
status,
completedCount,
totalAgents,
failedCount,
completionPercentage: Math.round((completedCount / totalAgents) * 100)
};
};
// Calculate comprehensive summary statistics for vulnerability analysis
export const calculateVulnerabilityAnalysisSummary = (session: Session): Readonly<{
totalAnalyses: number;
totalVulnerabilities: number;
exploitationCandidates: number;
completedAgents: AgentName[];
}> => {
const vulnAgents = PHASES['vulnerability-analysis'];
const completedVulnAgents = session.completedAgents.filter(agent =>
vulnAgents.includes(agent)
);
// NOTE: Actual vulnerability counts require reading queue files
// This summary only shows completion counts
return Object.freeze({
totalAnalyses: completedVulnAgents.length,
totalVulnerabilities: 0, // Requires reading queue files
exploitationCandidates: 0, // Requires reading queue files
completedAgents: completedVulnAgents
});
};
// Calculate exploitation summary statistics
export const calculateExploitationSummary = (session: Session): Readonly<{
totalAttempts: number;
eligibleExploits: number;
skippedExploits: number;
completedAgents: AgentName[];
}> => {
const exploitAgents = PHASES['exploitation'];
const completedExploitAgents = session.completedAgents.filter(agent =>
exploitAgents.includes(agent)
);
// NOTE: Eligibility requires reading queue files
// This summary only shows completion counts
return Object.freeze({
totalAttempts: completedExploitAgents.length,
eligibleExploits: 0, // Requires reading queue files
skippedExploits: 0, // Requires reading queue files
completedAgents: completedExploitAgents
});
};
// Rollback session to specific agent checkpoint
export const rollbackToAgent = async (
sessionId: string,
targetAgent: string
): Promise<Session> => {
const session = await getSession(sessionId);
if (!session) {
throw new PentestError(`Session ${sessionId} not found`, 'validation', false);
}
validateAgent(targetAgent);
if (!session.checkpoints[targetAgent as AgentName]) {
throw new PentestError(
`No checkpoint found for agent '${targetAgent}' in session history`,
'validation',
false,
{ targetAgent, availableCheckpoints: Object.keys(session.checkpoints) }
);
}
// Find agents that need to be removed (those after the target agent)
const targetOrder = AGENTS[targetAgent as AgentName]!.order;
const agentsToRemove = Object.values(AGENTS)
.filter(agent => agent.order > targetOrder)
.map(agent => agent.name);
const updates: Partial<Session> = {
completedAgents: session.completedAgents.filter(agent => !agentsToRemove.includes(agent)),
failedAgents: session.failedAgents.filter(agent => !agentsToRemove.includes(agent)),
checkpoints: Object.fromEntries(
Object.entries(session.checkpoints).filter(([agent]) => !agentsToRemove.includes(agent as AgentName))
) as Record<AgentName, string>
};
// NOTE: Timing and cost data now managed in audit-logs/session.json
// Rollback will be reflected via reconcileSession() which marks agents as "rolled-back"
return await updateSession(sessionId, updates);
};
/**
* Reconcile Shannon store with audit logs (self-healing)
*
* This function ensures the Shannon store (.shannon-store.json) is consistent with
* the audit logs (audit-logs/session.json) by syncing agent completion status.
*
* Three-part reconciliation:
* 1. PROMOTIONS: Agents completed/failed in audit → added to Shannon store
* 2. DEMOTIONS: Agents rolled-back in audit → removed from Shannon store
* 3. VERIFICATION: Ensure audit state fully reflected in orchestration
*
* Critical for crash recovery, especially crash during rollback operations.
*/
export const reconcileSession = async (sessionId: string): Promise<ReconciliationReport> => {
const { AuditSession } = await import('./audit/index.js');
// Get Shannon store session
const shannonSession = await getSession(sessionId);
if (!shannonSession) {
throw new PentestError(`Session ${sessionId} not found in Shannon store`, 'validation', false);
}
// Get audit session data - cast session to SessionMetadata for compatibility
const sessionMetadata: SessionMetadata = {
id: shannonSession.id,
webUrl: shannonSession.webUrl,
repoPath: shannonSession.repoPath,
};
const auditSession = new AuditSession(sessionMetadata);
await auditSession.initialize();
const auditData = await auditSession.getMetrics() as AuditMetricsData;
const report: ReconciliationReport = {
promotions: [],
demotions: [],
failures: []
};
// PART 1: PROMOTIONS (Additive)
// Find agents completed in audit but not in Shannon store
const auditCompleted = Object.entries(auditData.metrics.agents)
.filter(([, agentData]) => agentData.status === 'success')
.map(([agentName]) => agentName);
const missing = auditCompleted.filter(agent => !shannonSession.completedAgents.includes(agent as AgentName));
for (const agentName of missing) {
const agentData = auditData.metrics.agents[agentName];
const checkpoint = agentData?.checkpoint || '';
await markAgentCompleted(sessionId, agentName, checkpoint);
report.promotions.push(agentName);
}
// PART 2: DEMOTIONS (Subtractive) - CRITICAL FOR ROLLBACK RECOVERY
// Find agents rolled-back in audit but still in Shannon store
const auditRolledBack = Object.entries(auditData.metrics.agents)
.filter(([, agentData]) => agentData.status === 'rolled-back')
.map(([agentName]) => agentName);
const toRemove = shannonSession.completedAgents.filter(agent => auditRolledBack.includes(agent));
if (toRemove.length > 0) {
// Reload session to get fresh state
const freshSession = await getSession(sessionId);
if (freshSession) {
const updates: Partial<Session> = {
completedAgents: freshSession.completedAgents.filter(agent => !toRemove.includes(agent)),
checkpoints: Object.fromEntries(
Object.entries(freshSession.checkpoints).filter(([agent]) => !toRemove.includes(agent as AgentName))
) as Record<AgentName, string>
};
await updateSession(sessionId, updates);
report.demotions.push(...toRemove);
}
}
// PART 3: FAILURES
// Find agents failed in audit but not marked failed in Shannon store
const auditFailed = Object.entries(auditData.metrics.agents)
.filter(([, agentData]) => agentData.status === 'failed')
.map(([agentName]) => agentName);
const failedToAdd = auditFailed.filter(agent => !shannonSession.failedAgents.includes(agent as AgentName));
for (const agentName of failedToAdd) {
await markAgentFailed(sessionId, agentName);
report.failures.push(agentName);
}
return report;
};
// Delete a specific session by ID
export const deleteSession = async (sessionId: string): Promise<Session> => {
const store = await loadSessions();
if (!store.sessions[sessionId]) {
throw new PentestError(
`Session ${sessionId} not found`,
'validation',
false,
{ sessionId }
);
}
const deletedSession = store.sessions[sessionId]!;
delete store.sessions[sessionId];
await saveSessions(store);
return deletedSession;
};
// Delete all sessions (remove entire storage)
export const deleteAllSessions = async (): Promise<boolean> => {
try {
if (await fs.pathExists(STORE_FILE)) {
await fs.remove(STORE_FILE);
return true;
}
return false; // File didn't exist
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
throw new PentestError(
`Failed to delete session storage: ${errMsg}`,
'filesystem',
false,
{ storeFile: STORE_FILE, originalError: errMsg }
);
}
};

View File

@@ -6,7 +6,7 @@
// as published by the Free Software Foundation.
import { path, fs, $ } from 'zx';
import chalk from 'chalk';
import chalk, { type ChalkInstance } from 'chalk';
import dotenv from 'dotenv';
dotenv.config();
@@ -15,11 +15,9 @@ dotenv.config();
import { parseConfig, distributeConfig } from './config-parser.js';
import { checkToolAvailability, handleMissingTools } from './tool-checker.js';
// Session and Checkpoints
import { createSession, updateSession, getSession, AGENTS } from './session-manager.js';
import type { Session } from './session-manager.js';
import type { AgentName } from './types/index.js';
import { runPhase, getGitCommitHash } from './checkpoint-manager.js';
// Session
import { AGENTS, getParallelGroups } from './session-manager.js';
import type { AgentName, PromptName } from './types/index.js';
// Setup and Deliverables
import { setupLocalRepo } from './setup/environment.js';
@@ -33,56 +31,173 @@ import { executePreReconPhase } from './phases/pre-recon.js';
import { assembleFinalReport } from './phases/reporting.js';
// Utils
import { timingResults, costResults, displayTimingSummary, Timer } from './utils/metrics.js';
import { timingResults, displayTimingSummary, Timer } from './utils/metrics.js';
import { formatDuration, generateAuditPath } from './audit/utils.js';
import type { SessionMetadata } from './audit/utils.js';
import { AuditSession } from './audit/audit-session.js';
// CLI
import { handleDeveloperCommand } from './cli/command-handler.js';
import { showHelp, displaySplashScreen } from './cli/ui.js';
import { validateWebUrl, validateRepoPath } from './cli/input-validator.js';
// Error Handling
import { PentestError, logError } from './error-handling.js';
// Session Manager Functions
import {
calculateVulnerabilityAnalysisSummary,
calculateExploitationSummary,
getNextAgent
} from './session-manager.js';
import type { DistributedConfig } from './types/config.js';
import type { ToolAvailability } from './tool-checker.js';
import { safeValidateQueueAndDeliverable } from './queue-validation.js';
// Extend global namespace for SHANNON_DISABLE_LOADER
declare global {
var SHANNON_DISABLE_LOADER: boolean | undefined;
}
// Session Lock File Management
const STORE_PATH = path.join(process.cwd(), '.shannon-store.json');
interface Session {
id: string;
webUrl: string;
repoPath: string;
status: 'in-progress' | 'completed' | 'failed';
startedAt: string;
}
interface SessionStore {
sessions: Session[];
}
function generateSessionId(): string {
return crypto.randomUUID();
}
async function loadSessions(): Promise<SessionStore> {
try {
if (await fs.pathExists(STORE_PATH)) {
return await fs.readJson(STORE_PATH) as SessionStore;
}
} catch {
// Corrupted file, start fresh
}
return { sessions: [] };
}
async function saveSessions(store: SessionStore): Promise<void> {
await fs.writeJson(STORE_PATH, store, { spaces: 2 });
}
async function createSession(webUrl: string, repoPath: string): Promise<Session> {
const store = await loadSessions();
// Check for existing in-progress session
const existing = store.sessions.find(
s => s.repoPath === repoPath && s.status === 'in-progress'
);
if (existing) {
throw new PentestError(
`Session already in progress for ${repoPath}`,
'validation',
false,
{ sessionId: existing.id }
);
}
const session: Session = {
id: generateSessionId(),
webUrl,
repoPath,
status: 'in-progress',
startedAt: new Date().toISOString()
};
store.sessions.push(session);
await saveSessions(store);
return session;
}
async function updateSessionStatus(
sessionId: string,
status: 'in-progress' | 'completed' | 'failed'
): Promise<void> {
const store = await loadSessions();
const session = store.sessions.find(s => s.id === sessionId);
if (session) {
session.status = status;
await saveSessions(store);
}
}
interface PromptVariables {
webUrl: string;
repoPath: string;
sourceDir: string;
}
interface SessionUpdates {
completedAgents?: AgentName[];
failedAgents?: AgentName[];
status?: 'in-progress' | 'completed' | 'failed';
checkpoints?: Record<AgentName, string>;
}
interface MainResult {
reportPath: string;
auditLogsPath: string;
}
interface AgentResult {
success: boolean;
duration: number;
cost?: number;
error?: string;
retryable?: boolean;
}
interface ParallelAgentResult {
agentName: AgentName;
success: boolean;
timing?: number | undefined;
cost?: number | undefined;
attempts: number;
error?: string | undefined;
}
// Configure zx to disable timeouts (let tools run as long as needed)
$.timeout = 0;
// Helper function to get prompt name from agent name
const getPromptName = (agentName: AgentName): PromptName => {
const mappings: Record<AgentName, PromptName> = {
'pre-recon': 'pre-recon-code',
'recon': 'recon',
'injection-vuln': 'vuln-injection',
'xss-vuln': 'vuln-xss',
'auth-vuln': 'vuln-auth',
'ssrf-vuln': 'vuln-ssrf',
'authz-vuln': 'vuln-authz',
'injection-exploit': 'exploit-injection',
'xss-exploit': 'exploit-xss',
'auth-exploit': 'exploit-auth',
'ssrf-exploit': 'exploit-ssrf',
'authz-exploit': 'exploit-authz',
'report': 'report-executive'
};
return mappings[agentName] || agentName as PromptName;
};
// Get color function for agent
const getAgentColor = (agentName: AgentName): ChalkInstance => {
const colorMap: Partial<Record<AgentName, ChalkInstance>> = {
'injection-vuln': chalk.red,
'injection-exploit': chalk.red,
'xss-vuln': chalk.yellow,
'xss-exploit': chalk.yellow,
'auth-vuln': chalk.blue,
'auth-exploit': chalk.blue,
'ssrf-vuln': chalk.magenta,
'ssrf-exploit': chalk.magenta,
'authz-vuln': chalk.green,
'authz-exploit': chalk.green
};
return colorMap[agentName] || chalk.cyan;
};
/**
* Consolidate deliverables from target repo into the session folder
* Copies deliverables directory from source repo to session audit path
*/
async function consolidateOutputs(sourceDir: string, sessionPath: string): Promise<void> {
const srcDeliverables = path.join(sourceDir, 'deliverables');
@@ -101,6 +216,316 @@ async function consolidateOutputs(sourceDir: string, sessionPath: string): Promi
}
}
/**
* Run a single agent
*/
async function runAgent(
agentName: AgentName,
sourceDir: string,
variables: PromptVariables,
distributedConfig: DistributedConfig | null,
pipelineTestingMode: boolean,
sessionMetadata: SessionMetadata
): Promise<AgentResult> {
const agent = AGENTS[agentName];
const promptName = getPromptName(agentName);
const prompt = await loadPrompt(promptName, variables, distributedConfig, pipelineTestingMode);
return await runClaudePromptWithRetry(
prompt,
sourceDir,
'*',
'',
agent.displayName,
agentName,
getAgentColor(agentName),
sessionMetadata
);
}
/**
* Run vulnerability agents in parallel
*/
async function runParallelVuln(
sourceDir: string,
variables: PromptVariables,
distributedConfig: DistributedConfig | null,
pipelineTestingMode: boolean,
sessionMetadata: SessionMetadata
): Promise<ParallelAgentResult[]> {
const { vuln: vulnAgents } = getParallelGroups();
console.log(chalk.cyan(`\nStarting ${vulnAgents.length} vulnerability analysis specialists in parallel...`));
console.log(chalk.gray(' Specialists: ' + vulnAgents.join(', ')));
console.log();
const startTime = Date.now();
const results = await Promise.allSettled(
vulnAgents.map(async (agentName, index) => {
// Add 2-second stagger to prevent API overwhelm
await new Promise(resolve => setTimeout(resolve, index * 2000));
let lastError: Error | undefined;
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
attempts++;
try {
const result = await runAgent(
agentName,
sourceDir,
variables,
distributedConfig,
pipelineTestingMode,
sessionMetadata
);
// Validate vulnerability analysis results
const vulnType = agentName.replace('-vuln', '');
try {
const validation = await safeValidateQueueAndDeliverable(vulnType as 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz', sourceDir);
if (validation.success && validation.data) {
console.log(chalk.blue(`${agentName}: ${validation.data.shouldExploit ? `Ready for exploitation (${validation.data.vulnerabilityCount} vulnerabilities)` : 'No vulnerabilities found'}`));
}
} catch {
// Validation failure is non-critical
}
return {
agentName,
success: result.success,
timing: result.duration,
cost: result.cost,
attempts
};
} catch (error) {
lastError = error as Error;
if (attempts < maxAttempts) {
console.log(chalk.yellow(`Warning: ${agentName} failed attempt ${attempts}/${maxAttempts}, retrying...`));
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
return {
agentName,
success: false,
attempts,
error: lastError?.message || 'Unknown error'
};
})
);
const totalDuration = Date.now() - startTime;
// Process and display results
console.log(chalk.cyan('\nVulnerability Analysis Results'));
console.log(chalk.gray('-'.repeat(80)));
console.log(chalk.bold('Agent Status Attempt Duration Cost'));
console.log(chalk.gray('-'.repeat(80)));
const processedResults: ParallelAgentResult[] = [];
results.forEach((result, index) => {
const agentName = vulnAgents[index]!;
const agentDisplay = agentName.padEnd(22);
if (result.status === 'fulfilled') {
const data = result.value;
processedResults.push(data);
if (data.success) {
const duration = formatDuration(data.timing || 0);
const cost = `$${(data.cost || 0).toFixed(4)}`;
console.log(
`${chalk.green(agentDisplay)} ${chalk.green('Success')} ` +
`${data.attempts}/3 ${duration.padEnd(11)} ${cost}`
);
} else {
console.log(
`${chalk.red(agentDisplay)} ${chalk.red('Failed ')} ` +
`${data.attempts}/3 - -`
);
if (data.error) {
console.log(chalk.gray(` Error: ${data.error.substring(0, 60)}...`));
}
}
} else {
processedResults.push({
agentName,
success: false,
attempts: 3,
error: String(result.reason)
});
console.log(
`${chalk.red(agentDisplay)} ${chalk.red('Failed ')} ` +
`3/3 - -`
);
}
});
console.log(chalk.gray('-'.repeat(80)));
const successCount = processedResults.filter(r => r.success).length;
console.log(chalk.cyan(`Summary: ${successCount}/${vulnAgents.length} succeeded in ${formatDuration(totalDuration)}`));
return processedResults;
}
/**
* Run exploitation agents in parallel
*/
async function runParallelExploit(
sourceDir: string,
variables: PromptVariables,
distributedConfig: DistributedConfig | null,
pipelineTestingMode: boolean,
sessionMetadata: SessionMetadata
): Promise<ParallelAgentResult[]> {
const { exploit: exploitAgents, vuln: vulnAgents } = getParallelGroups();
// Load validation module
const { safeValidateQueueAndDeliverable } = await import('./queue-validation.js');
// Check eligibility
const eligibilityChecks = await Promise.all(
exploitAgents.map(async (agentName) => {
const vulnAgentName = agentName.replace('-exploit', '-vuln') as AgentName;
const vulnType = vulnAgentName.replace('-vuln', '') as 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz';
const validation = await safeValidateQueueAndDeliverable(vulnType, sourceDir);
if (!validation.success || !validation.data?.shouldExploit) {
console.log(chalk.gray(`Skipping ${agentName} (no vulnerabilities found in ${vulnAgentName})`));
return { agentName, eligible: false };
}
console.log(chalk.blue(`${agentName} eligible (${validation.data.vulnerabilityCount} vulnerabilities from ${vulnAgentName})`));
return { agentName, eligible: true };
})
);
const eligibleAgents = eligibilityChecks
.filter(check => check.eligible)
.map(check => check.agentName);
if (eligibleAgents.length === 0) {
console.log(chalk.gray('No exploitation agents eligible (no vulnerabilities found)'));
return [];
}
console.log(chalk.cyan(`\nStarting ${eligibleAgents.length} exploitation specialists in parallel...`));
console.log(chalk.gray(' Specialists: ' + eligibleAgents.join(', ')));
console.log();
const startTime = Date.now();
const results = await Promise.allSettled(
eligibleAgents.map(async (agentName, index) => {
await new Promise(resolve => setTimeout(resolve, index * 2000));
let lastError: Error | undefined;
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
attempts++;
try {
const result = await runAgent(
agentName,
sourceDir,
variables,
distributedConfig,
pipelineTestingMode,
sessionMetadata
);
return {
agentName,
success: result.success,
timing: result.duration,
cost: result.cost,
attempts
};
} catch (error) {
lastError = error as Error;
if (attempts < maxAttempts) {
console.log(chalk.yellow(`Warning: ${agentName} failed attempt ${attempts}/${maxAttempts}, retrying...`));
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
return {
agentName,
success: false,
attempts,
error: lastError?.message || 'Unknown error'
};
})
);
const totalDuration = Date.now() - startTime;
// Process and display results
console.log(chalk.cyan('\nExploitation Results'));
console.log(chalk.gray('-'.repeat(80)));
console.log(chalk.bold('Agent Status Attempt Duration Cost'));
console.log(chalk.gray('-'.repeat(80)));
const processedResults: ParallelAgentResult[] = [];
results.forEach((result, index) => {
const agentName = eligibleAgents[index]!;
const agentDisplay = agentName.padEnd(22);
if (result.status === 'fulfilled') {
const data = result.value;
processedResults.push(data);
if (data.success) {
const duration = formatDuration(data.timing || 0);
const cost = `$${(data.cost || 0).toFixed(4)}`;
console.log(
`${chalk.green(agentDisplay)} ${chalk.green('Success')} ` +
`${data.attempts}/3 ${duration.padEnd(11)} ${cost}`
);
} else {
console.log(
`${chalk.red(agentDisplay)} ${chalk.red('Failed ')} ` +
`${data.attempts}/3 - -`
);
if (data.error) {
console.log(chalk.gray(` Error: ${data.error.substring(0, 60)}...`));
}
}
} else {
processedResults.push({
agentName,
success: false,
attempts: 3,
error: String(result.reason)
});
console.log(
`${chalk.red(agentDisplay)} ${chalk.red('Failed ')} ` +
`3/3 - -`
);
}
});
console.log(chalk.gray('-'.repeat(80)));
const successCount = processedResults.filter(r => r.success).length;
console.log(chalk.cyan(`Summary: ${successCount}/${eligibleAgents.length} succeeded in ${formatDuration(totalDuration)}`));
return processedResults;
}
// Setup graceful cleanup on process signals
process.on('SIGINT', async () => {
console.log(chalk.yellow('\n⚠ Received SIGINT, cleaning up...'));
@@ -190,40 +615,16 @@ async function main(
const variables: PromptVariables = { webUrl, repoPath, sourceDir };
// Create session for tracking (in normal mode)
const session: Session = await createSession(webUrl, repoPath, configPath, sourceDir, outputPath);
console.log(chalk.blue(`📝 Session created: ${session.id.substring(0, 8)}...`));
// Create session (acts as lock file)
const session: Session = await createSession(webUrl, repoPath);
console.log(chalk.blue(`Session created: ${session.id.substring(0, 8)}...`));
// If setup-only mode, exit after session creation
if (process.argv.includes('--setup-only')) {
console.log(chalk.green('✅ Setup complete! Local repository setup and session created.'));
console.log(chalk.gray('Use developer commands to run individual agents:'));
console.log(chalk.gray(' shannon --run-agent pre-recon'));
console.log(chalk.gray(' shannon --status'));
process.exit(0);
}
// Helper function to update session progress
const updateSessionProgress = async (agentName: AgentName, commitHash: string | null = null): Promise<void> => {
try {
const updates: SessionUpdates = {
completedAgents: [...new Set([...session.completedAgents, agentName])] as AgentName[],
failedAgents: session.failedAgents.filter(name => name !== agentName),
status: 'in-progress'
};
if (commitHash) {
updates.checkpoints = { ...session.checkpoints, [agentName]: commitHash };
}
await updateSession(session.id, updates);
// Update local session object for subsequent updates
Object.assign(session, updates);
console.log(chalk.gray(` 📝 Session updated: ${agentName} completed`));
} catch (error) {
const err = error as Error;
console.log(chalk.yellow(` ⚠️ Failed to update session: ${err.message}`));
}
// Session metadata for audit logging
const sessionMetadata: SessionMetadata = {
id: session.id,
webUrl,
repoPath: sourceDir,
...(outputPath && { outputPath })
};
// Create outputs directory in source directory
@@ -242,25 +643,8 @@ async function main(
);
}
// Check if we should continue from where session left off
const nextAgent = getNextAgent(session);
if (!nextAgent) {
console.log(chalk.green(`✅ All agents completed! Session is finished.`));
displayTimingSummary();
process.exit(0);
}
console.log(chalk.blue(`🔄 Continuing from ${nextAgent.displayName} (${session.completedAgents.length}/${Object.keys(AGENTS).length} agents completed)`));
// Determine which phase to start from based on next agent
const startPhase = nextAgent.name === 'pre-recon' ? 1
: nextAgent.name === 'recon' ? 2
: ['injection-vuln', 'xss-vuln', 'auth-vuln', 'ssrf-vuln', 'authz-vuln'].includes(nextAgent.name) ? 3
: ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit'].includes(nextAgent.name) ? 4
: nextAgent.name === 'report' ? 5 : 1;
try {
// PHASE 1: PRE-RECONNAISSANCE
if (startPhase <= 1) {
const { duration: preReconDuration } = await executePreReconPhase(
webUrl,
sourceDir,
@@ -268,92 +652,64 @@ async function main(
distributedConfig,
toolAvailability,
pipelineTestingMode,
session.id, // Pass session ID for logging
outputPath // Pass output path for audit logging
session.id,
outputPath
);
timingResults.phases['pre-recon'] = preReconDuration;
await updateSessionProgress('pre-recon');
}
console.log(chalk.green(`Pre-reconnaissance complete in ${formatDuration(preReconDuration)}`));
// PHASE 2: RECONNAISSANCE
if (startPhase <= 2) {
console.log(chalk.magenta.bold('\n🔎 PHASE 2: RECONNAISSANCE'));
console.log(chalk.magenta('Analyzing initial findings...'));
const reconTimer = new Timer('phase-2-recon');
await runClaudePromptWithRetry(
await loadPrompt('recon', variables, distributedConfig, pipelineTestingMode),
await runAgent(
'recon',
sourceDir,
'*',
'',
AGENTS['recon'].displayName,
'recon', // Agent name for snapshot creation
chalk.cyan,
{ id: session.id, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field)
variables,
distributedConfig,
pipelineTestingMode,
sessionMetadata
);
const reconDuration = reconTimer.stop();
timingResults.phases['recon'] = reconDuration;
console.log(chalk.green(`✅ Reconnaissance complete in ${formatDuration(reconDuration)}`));
await updateSessionProgress('recon');
}
// PHASE 3: VULNERABILITY ANALYSIS
if (startPhase <= 3) {
const vulnTimer = new Timer('phase-3-vulnerability-analysis');
console.log(chalk.red.bold('\n🚨 PHASE 3: VULNERABILITY ANALYSIS'));
await runPhase('vulnerability-analysis', session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
// Display vulnerability analysis summary
const currentSession = await getSession(session.id);
if (currentSession) {
const vulnSummary = calculateVulnerabilityAnalysisSummary(currentSession);
console.log(chalk.blue(`\n📊 Vulnerability Analysis Summary: ${vulnSummary.totalAnalyses} analyses, ${vulnSummary.totalVulnerabilities} vulnerabilities found, ${vulnSummary.exploitationCandidates} ready for exploitation`));
}
const vulnResults = await runParallelVuln(
sourceDir,
variables,
distributedConfig,
pipelineTestingMode,
sessionMetadata
);
const vulnDuration = vulnTimer.stop();
timingResults.phases['vulnerability-analysis'] = vulnDuration;
console.log(chalk.green(`✅ Vulnerability analysis phase complete in ${formatDuration(vulnDuration)}`));
}
// PHASE 4: EXPLOITATION
if (startPhase <= 4) {
const exploitTimer = new Timer('phase-4-exploitation');
console.log(chalk.red.bold('\n💥 PHASE 4: EXPLOITATION'));
// Get fresh session data to ensure we have latest vulnerability analysis results
const freshSession = await getSession(session.id);
if (freshSession) {
await runPhase('exploitation', freshSession, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
}
// Display exploitation summary
const finalSession = await getSession(session.id);
if (finalSession) {
const exploitSummary = calculateExploitationSummary(finalSession);
if (exploitSummary.eligibleExploits > 0) {
console.log(chalk.blue(`\n🎯 Exploitation Summary: ${exploitSummary.totalAttempts}/${exploitSummary.eligibleExploits} attempted, ${exploitSummary.skippedExploits} skipped (no vulnerabilities)`));
} else {
console.log(chalk.gray(`\n🎯 Exploitation Summary: No exploitation attempts (no vulnerabilities found)`));
}
}
const exploitResults = await runParallelExploit(
sourceDir,
variables,
distributedConfig,
pipelineTestingMode,
sessionMetadata
);
const exploitDuration = exploitTimer.stop();
timingResults.phases['exploitation'] = exploitDuration;
console.log(chalk.green(`✅ Exploitation phase complete in ${formatDuration(exploitDuration)}`));
}
// PHASE 5: REPORTING
if (startPhase <= 5) {
console.log(chalk.greenBright.bold('\n📊 PHASE 5: REPORTING'));
console.log(chalk.greenBright('Generating executive summary and assembling final report...'));
const reportTimer = new Timer('phase-5-reporting');
// First, assemble all deliverables into a single concatenated report
// Assemble all deliverables into a single concatenated report
console.log(chalk.blue('📝 Assembling deliverables from specialist agents...'));
try {
await assembleFinalReport(sourceDir);
} catch (error) {
@@ -361,62 +717,58 @@ async function main(
console.log(chalk.red(`❌ Error assembling final report: ${err.message}`));
}
// Then run reporter agent to create executive summary and clean up hallucinations
console.log(chalk.blue('📋 Generating executive summary and cleaning up report...'));
await runClaudePromptWithRetry(
await loadPrompt('report-executive', variables, distributedConfig, pipelineTestingMode),
// Run reporter agent to create executive summary
console.log(chalk.blue('Generating executive summary and cleaning up report...'));
await runAgent(
'report',
sourceDir,
'*',
'',
'Executive Summary and Report Cleanup',
'report', // Agent name for snapshot creation
chalk.cyan,
{ id: session.id, webUrl, repoPath: sourceDir, ...(outputPath && { outputPath }) } // Session metadata for audit logging (STANDARD: use 'id' field)
variables,
distributedConfig,
pipelineTestingMode,
sessionMetadata
);
const reportDuration = reportTimer.stop();
timingResults.phases['reporting'] = reportDuration;
console.log(chalk.green(`✅ Final report generated in ${formatDuration(reportDuration)}`));
// Get the commit hash after successful report generation for checkpoint
try {
const reportCommitHash = await getGitCommitHash(sourceDir);
await updateSessionProgress('report', reportCommitHash);
console.log(chalk.gray(` 📍 Report checkpoint saved: ${reportCommitHash.substring(0, 8)}`));
} catch (error) {
const err = error as Error;
console.log(chalk.yellow(` ⚠️ Failed to save report checkpoint: ${err.message}`));
await updateSessionProgress('report'); // Fallback without checkpoint
}
}
// Calculate final timing
timingResults.total.stop();
// Calculate final timing and cost data
timingResults.total.stop();
// Mark session as completed in both stores
await updateSessionStatus(session.id, 'completed');
// Mark session as completed
await updateSession(session.id, {
status: 'completed'
});
// Update audit system's session.json status
const auditSession = new AuditSession(sessionMetadata);
await auditSession.updateSessionStatus('completed');
// Display comprehensive timing summary
displayTimingSummary();
// Display comprehensive timing summary
displayTimingSummary();
console.log(chalk.cyan.bold('\n🎉 PENETRATION TESTING COMPLETE!'));
console.log(chalk.gray('─'.repeat(60)));
// Calculate audit logs path
const auditLogsPath = generateAuditPath({ id: session.id, webUrl: session.webUrl, repoPath: session.repoPath, ...(outputPath && { outputPath }) });
const auditLogsPath = generateAuditPath(sessionMetadata);
// Consolidate deliverables into the session folder
await consolidateOutputs(sourceDir, auditLogsPath);
console.log(chalk.green(`\n📂 All outputs consolidated: ${auditLogsPath}`));
// Return final report path and audit logs path for clickable output
return {
reportPath: path.join(sourceDir, 'deliverables', 'comprehensive_security_assessment_report.md'),
auditLogsPath
};
return {
reportPath: path.join(sourceDir, 'deliverables', 'comprehensive_security_assessment_report.md'),
auditLogsPath
};
} catch (error) {
// Mark session as failed in both stores
await updateSessionStatus(session.id, 'failed');
// Update audit system's session.json status
const auditSession = new AuditSession(sessionMetadata);
await auditSession.updateSessionStatus('failed');
throw error;
}
}
// Entry point - handle both direct node execution and shebang execution
@@ -432,8 +784,6 @@ let outputPath: string | null = null;
let pipelineTestingMode = false;
let disableLoader = false;
const nonFlagArgs: string[] = [];
let developerCommand: string | null = null;
const developerCommands = ['--run-phase', '--run-all', '--rollback-to', '--rerun', '--status', '--list-agents', '--cleanup'];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--config') {
@@ -456,24 +806,6 @@ for (let i = 0; i < args.length; i++) {
pipelineTestingMode = true;
} else if (args[i] === '--disable-loader') {
disableLoader = true;
} else if (developerCommands.includes(args[i]!)) {
developerCommand = args[i]!;
// Collect remaining args for the developer command
const remainingArgs = args.slice(i + 1).filter(arg => !arg.startsWith('--') || arg === '--pipeline-testing' || arg === '--disable-loader');
// Check for --pipeline-testing in remaining args
if (remainingArgs.includes('--pipeline-testing')) {
pipelineTestingMode = true;
}
// Check for --disable-loader in remaining args
if (remainingArgs.includes('--disable-loader')) {
disableLoader = true;
}
// Add non-flag args (excluding --pipeline-testing and --disable-loader)
nonFlagArgs.push(...remainingArgs.filter(arg => arg !== '--pipeline-testing' && arg !== '--disable-loader'));
break; // Stop parsing after developer command
} else if (!args[i]!.startsWith('-')) {
nonFlagArgs.push(args[i]!);
}
@@ -485,16 +817,6 @@ if (args.includes('--help') || args.includes('-h') || args.includes('help')) {
process.exit(0);
}
// Handle developer commands
if (developerCommand) {
// Set global flag for loader control in developer mode too
global.SHANNON_DISABLE_LOADER = disableLoader;
await handleDeveloperCommand(developerCommand, nonFlagArgs, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
process.exit(0);
}
// Handle no arguments - show help
if (nonFlagArgs.length === 0) {
console.log(chalk.red.bold('❌ Error: No arguments provided\n'));

View File

@@ -51,23 +51,15 @@ export type AgentValidatorMap = Record<AgentName, AgentValidator>;
export type McpAgentMapping = Record<PromptName, PlaywrightAgent>;
export type AgentPhase =
| 'pre-recon'
| 'recon'
| 'vuln'
| 'exploit'
| 'report';
export interface AgentDefinition {
name: AgentName;
promptName: PromptName;
phase: AgentPhase;
dependencies?: AgentName[];
}
export type AgentStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed'
| 'rolled-back';
export interface AgentDefinition {
name: AgentName;
displayName: string;
prerequisites: AgentName[];
}

View File

@@ -10,5 +10,4 @@
export * from './errors.js';
export * from './config.js';
export * from './session.js';
export * from './agents.js';

View File

@@ -1,63 +0,0 @@
// 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.
/**
* Session type definitions
*/
import type { AgentName, AgentStatus } from './agents.js';
export type PhaseName =
| 'pre-reconnaissance'
| 'reconnaissance'
| 'vulnerability-analysis'
| 'exploitation'
| 'reporting';
export interface AgentInfo {
name: AgentName;
displayName: string;
phase: PhaseName;
order: number;
prerequisites: AgentName[];
}
export type AgentDefinitions = Record<AgentName, AgentInfo>;
export type PhaseDefinitions = Record<PhaseName, AgentName[]>;
export interface AgentState {
status: AgentStatus;
startedAt?: string;
completedAt?: string;
error?: string;
attempts?: number;
}
export interface Session {
id: string;
targetUrl: string;
repoPath: string;
configPath?: string;
createdAt: string;
updatedAt: string;
completedAgents: AgentName[];
agentStates: Record<AgentName, AgentState>;
checkpoints: Record<AgentName, string>;
}
export interface SessionStore {
sessions: Record<string, Session>;
}
export interface SessionSummary {
id: string;
targetUrl: string;
repoPath: string;
createdAt: string;
completedAgents: number;
totalAgents: number;
}

View File

@@ -30,22 +30,12 @@ export class Timer {
}
}
interface TimingResultsPhases {
[key: string]: number;
}
interface TimingResultsCommands {
[key: string]: number;
}
interface TimingResultsAgents {
[key: string]: number;
}
interface TimingResults {
total: Timer | null;
phases: TimingResultsPhases;
commands: TimingResultsCommands;
agents: TimingResultsAgents;
}
@@ -61,8 +51,6 @@ interface CostResults {
// Global timing and cost tracker
export const timingResults: TimingResults = {
total: null,
phases: {},
commands: {},
agents: {},
};
@@ -87,44 +75,6 @@ export const displayTimingSummary = (): void => {
console.log(chalk.cyan(`📊 Total Execution Time: ${formatDuration(totalDuration)}`));
console.log();
// Phase breakdown
if (Object.keys(timingResults.phases).length > 0) {
console.log(chalk.yellow.bold('🔍 Phase Breakdown:'));
let phaseTotal = 0;
for (const [phase, duration] of Object.entries(timingResults.phases)) {
const percentage = ((duration / totalDuration) * 100).toFixed(1);
console.log(
chalk.yellow(` ${phase.padEnd(20)} ${formatDuration(duration).padStart(8)} (${percentage}%)`)
);
phaseTotal += duration;
}
console.log(
chalk.gray(
` ${'Phases Total'.padEnd(20)} ${formatDuration(phaseTotal).padStart(8)} (${((phaseTotal / totalDuration) * 100).toFixed(1)}%)`
)
);
console.log();
}
// Command breakdown
if (Object.keys(timingResults.commands).length > 0) {
console.log(chalk.blue.bold('🖥️ Command Breakdown:'));
let commandTotal = 0;
for (const [command, duration] of Object.entries(timingResults.commands)) {
const percentage = ((duration / totalDuration) * 100).toFixed(1);
console.log(
chalk.blue(` ${command.padEnd(20)} ${formatDuration(duration).padStart(8)} (${percentage}%)`)
);
commandTotal += duration;
}
console.log(
chalk.gray(
` ${'Commands Total'.padEnd(20)} ${formatDuration(commandTotal).padStart(8)} (${((commandTotal / totalDuration) * 100).toFixed(1)}%)`
)
);
console.log();
}
// Agent breakdown
if (Object.keys(timingResults.agents).length > 0) {
console.log(chalk.magenta.bold('🤖 Agent Breakdown:'));