mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-04-29 15:47:48 +02:00
feat: typescript migration (#40)
* chore: initialize TypeScript configuration and build setup - Add tsconfig.json for root and mcp-server with strict type checking - Install typescript and @types/node as devDependencies - Add npm build script for TypeScript compilation - Update main entrypoint to compiled dist/shannon.js - Update Dockerfile to build TypeScript before running - Configure output directory and module resolution for Node.js * refactor: migrate codebase from JavaScript to TypeScript - Convert all 37 JavaScript files to TypeScript (.js -> .ts) - Add type definitions in src/types/ for agents, config, errors, session - Update mcp-server with proper TypeScript types - Move entry point from shannon.mjs to src/shannon.ts - Update tsconfig.json with rootDir: "./src" for cleaner dist output - Update Dockerfile to build TypeScript before runtime - Update package.json paths to use compiled dist/shannon.js No runtime behavior changes - pure type safety migration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: update CLI references from ./shannon.mjs to shannon - Update help text in src/cli/ui.ts - Update usage examples in src/cli/command-handler.ts - Update setup message in src/shannon.ts - Update CLAUDE.md documentation with TypeScript file structure - Replace all ./shannon.mjs references with shannon command 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * chore: remove unnecessary eslint-disable comments ESLint is not configured in this project, making these comments redundant. 🤖 Generated with [Claude Code](https://claude.com/claude-code) 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:
+2
-1
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
.shannon-store.json
|
||||
agent-logs/
|
||||
/audit-logs/
|
||||
/audit-logs/
|
||||
dist/
|
||||
|
||||
@@ -15,13 +15,13 @@ npm install
|
||||
|
||||
### Running the Penetration Testing Agent
|
||||
```bash
|
||||
./shannon.mjs <WEB_URL> <REPO_PATH> --config <CONFIG_FILE>
|
||||
shannon <WEB_URL> <REPO_PATH> --config <CONFIG_FILE>
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
./shannon.mjs "https://example.com" "/path/to/local/repo"
|
||||
./shannon.mjs "https://juice-shop.herokuapp.com" "/home/user/juice-shop" --config juice-shop-config.yaml
|
||||
shannon "https://example.com" "/path/to/local/repo"
|
||||
shannon "https://juice-shop.herokuapp.com" "/home/user/juice-shop" --config juice-shop-config.yaml
|
||||
```
|
||||
|
||||
### Alternative Execution
|
||||
@@ -32,7 +32,7 @@ npm start <WEB_URL> <REPO_PATH> --config <CONFIG_FILE>
|
||||
### Configuration Validation
|
||||
```bash
|
||||
# Configuration validation is built into the main script
|
||||
./shannon.mjs --help # Shows usage and validates config on execution
|
||||
shannon --help # Shows usage and validates config on execution
|
||||
```
|
||||
|
||||
### Generate TOTP for Authentication
|
||||
@@ -42,73 +42,73 @@ 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.mjs <commands> --pipeline-testing
|
||||
shannon <commands> --pipeline-testing
|
||||
```
|
||||
|
||||
### Session Management Commands
|
||||
```bash
|
||||
# Setup session without running
|
||||
./shannon.mjs --setup-only <WEB_URL> <REPO_PATH> --config <CONFIG_FILE>
|
||||
shannon --setup-only <WEB_URL> <REPO_PATH> --config <CONFIG_FILE>
|
||||
|
||||
# Check session status (shows progress, timing, costs)
|
||||
./shannon.mjs --status
|
||||
shannon --status
|
||||
|
||||
# List all available agents by phase
|
||||
./shannon.mjs --list-agents
|
||||
shannon --list-agents
|
||||
|
||||
# Show help
|
||||
./shannon.mjs --help
|
||||
shannon --help
|
||||
```
|
||||
|
||||
### Execution Commands
|
||||
```bash
|
||||
# Run all remaining agents to completion
|
||||
./shannon.mjs --run-all [--pipeline-testing]
|
||||
shannon --run-all [--pipeline-testing]
|
||||
|
||||
# Run a specific agent
|
||||
./shannon.mjs --run-agent <agent-name> [--pipeline-testing]
|
||||
shannon --run-agent <agent-name> [--pipeline-testing]
|
||||
|
||||
# Run a range of agents
|
||||
./shannon.mjs --run-agents <start-agent>:<end-agent> [--pipeline-testing]
|
||||
shannon --run-agents <start-agent>:<end-agent> [--pipeline-testing]
|
||||
|
||||
# Run a specific phase
|
||||
./shannon.mjs --run-phase <phase-name> [--pipeline-testing]
|
||||
shannon --run-phase <phase-name> [--pipeline-testing]
|
||||
|
||||
# Pipeline testing mode (minimal prompts for fast testing)
|
||||
./shannon.mjs <command> --pipeline-testing
|
||||
shannon <command> --pipeline-testing
|
||||
```
|
||||
|
||||
### Rollback & Recovery Commands
|
||||
```bash
|
||||
# Rollback to specific checkpoint
|
||||
./shannon.mjs --rollback-to <agent-name>
|
||||
shannon --rollback-to <agent-name>
|
||||
|
||||
# Rollback and re-execute specific agent
|
||||
./shannon.mjs --rerun <agent-name> [--pipeline-testing]
|
||||
shannon --rerun <agent-name> [--pipeline-testing]
|
||||
```
|
||||
|
||||
### Session Cleanup Commands
|
||||
```bash
|
||||
# Delete all sessions (with confirmation)
|
||||
./shannon.mjs --cleanup
|
||||
shannon --cleanup
|
||||
|
||||
# Delete specific session by ID
|
||||
./shannon.mjs --cleanup <session-id>
|
||||
shannon --cleanup <session-id>
|
||||
```
|
||||
|
||||
## Architecture & Components
|
||||
|
||||
### Main Entry Point
|
||||
- `shannon.mjs` - Main orchestration script that coordinates the entire penetration testing workflow
|
||||
- `src/shannon.ts` - Main orchestration script that coordinates the entire penetration testing workflow (compiles to `dist/shannon.js`)
|
||||
|
||||
### Core Modules
|
||||
- `src/config-parser.js` - Handles YAML configuration parsing, validation, and distribution to agents
|
||||
- `src/error-handling.js` - Comprehensive error handling with retry logic and categorized error types
|
||||
- `src/tool-checker.js` - Validates availability of external security tools before execution
|
||||
- `src/session-manager.js` - Manages persistent session state and agent lifecycle
|
||||
- `src/checkpoint-manager.js` - Git-based checkpointing system for rollback capabilities
|
||||
- Pipeline orchestration is built into the main `shannon.mjs` script
|
||||
- `src/queue-validation.js` - Validates deliverables and agent prerequisites
|
||||
- `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/queue-validation.ts` - Validates deliverables and agent prerequisites
|
||||
|
||||
### Five-Phase Testing Workflow
|
||||
|
||||
@@ -259,25 +259,34 @@ The tool should only be used on systems you own or have explicit permission to t
|
||||
## File Structure
|
||||
|
||||
```
|
||||
shannon.mjs # Main orchestration script
|
||||
src/ # TypeScript source files
|
||||
├── shannon.ts # Main orchestration script (entry point)
|
||||
├── 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)
|
||||
│ ├── 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
|
||||
└── utils/
|
||||
dist/ # Compiled JavaScript output
|
||||
├── shannon.js # Compiled entry point
|
||||
└── ... # Other compiled files
|
||||
package.json # Node.js dependencies
|
||||
.shannon-store.json # Orchestration state (minimal)
|
||||
src/ # Core modules
|
||||
├── audit/ # Unified audit system (v3.0)
|
||||
│ ├── index.js # Public API
|
||||
│ ├── audit-session.js # Main facade (logger + metrics + mutex)
|
||||
│ ├── logger.js # Append-only crash-safe logging
|
||||
│ ├── metrics-tracker.js # Timing, cost, attempt tracking
|
||||
│ └── utils.js # Path generation, atomic writes
|
||||
├── config-parser.js # Configuration handling
|
||||
├── error-handling.js # Error management
|
||||
├── tool-checker.js # Tool validation
|
||||
├── session-manager.js # Session state + reconciliation
|
||||
├── checkpoint-manager.js # Git-based checkpointing + rollback
|
||||
├── queue-validation.js # Deliverable validation
|
||||
├── ai/
|
||||
│ └── claude-executor.js # Claude Agent SDK integration
|
||||
└── utils/
|
||||
audit-logs/ # Centralized audit data (v3.0)
|
||||
└── {hostname}_{sessionId}/
|
||||
├── session.json # Comprehensive metrics
|
||||
|
||||
+15
-9
@@ -108,18 +108,25 @@ RUN addgroup -g 1001 pentest && \
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json first for better caching
|
||||
# Copy package files first for better caching
|
||||
COPY package*.json ./
|
||||
COPY mcp-server/package*.json ./mcp-server/
|
||||
|
||||
# Install Node.js dependencies as root
|
||||
RUN npm ci --only=production && \
|
||||
npm install -g zx && \
|
||||
npm install -g @anthropic-ai/claude-agent-sdk && \
|
||||
# Install Node.js dependencies (including devDependencies for TypeScript build)
|
||||
RUN npm ci && \
|
||||
cd mcp-server && npm ci && cd .. && \
|
||||
npm cache clean --force
|
||||
|
||||
# Copy application code
|
||||
# Copy application source code
|
||||
COPY . .
|
||||
|
||||
# Build TypeScript (mcp-server first, then main project)
|
||||
RUN cd mcp-server && npm run build && cd .. && npm run build
|
||||
|
||||
# Remove devDependencies after build to reduce image size
|
||||
RUN npm prune --production && \
|
||||
cd mcp-server && npm prune --production
|
||||
|
||||
# Create directories for session data and ensure proper permissions
|
||||
RUN mkdir -p /app/sessions /app/deliverables /app/repos /app/configs && \
|
||||
mkdir -p /tmp/.cache /tmp/.config /tmp/.npm && \
|
||||
@@ -127,8 +134,7 @@ RUN mkdir -p /app/sessions /app/deliverables /app/repos /app/configs && \
|
||||
chmod 777 /tmp/.cache && \
|
||||
chmod 777 /tmp/.config && \
|
||||
chmod 777 /tmp/.npm && \
|
||||
chown -R pentest:pentest /app && \
|
||||
chmod +x /app/shannon.mjs
|
||||
chown -R pentest:pentest /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER pentest
|
||||
@@ -148,4 +154,4 @@ ENV XDG_CACHE_HOME=/tmp/.cache
|
||||
ENV XDG_CONFIG_HOME=/tmp/.config
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["./shannon.mjs"]
|
||||
ENTRYPOINT ["node", "dist/shannon.js"]
|
||||
|
||||
Generated
+35
@@ -10,6 +10,10 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
@@ -241,6 +245,37 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
|
||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
"name": "@shannon/mcp-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.js",
|
||||
"main": "./dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,14 @@ import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { saveDeliverableTool } from './tools/save-deliverable.js';
|
||||
import { generateTotpTool } from './tools/generate-totp.js';
|
||||
|
||||
declare global {
|
||||
var __SHANNON_TARGET_DIR: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Shannon Helper MCP Server with target directory context
|
||||
*
|
||||
* @param {string} targetDir - The target repository directory where deliverables should be saved
|
||||
* @returns {Object} MCP server instance
|
||||
*/
|
||||
export function createShannonHelperServer(targetDir) {
|
||||
export function createShannonHelperServer(targetDir: string): ReturnType<typeof createSdkMcpServer> {
|
||||
// Store target directory for tool access
|
||||
global.__SHANNON_TARGET_DIR = targetDir;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createHmac } from 'crypto';
|
||||
import { z } from 'zod';
|
||||
import { createToolResult } from '../types/tool-responses.js';
|
||||
import { createToolResult, type ToolResult, type GenerateTotpResponse } from '../types/tool-responses.js';
|
||||
import { base32Decode, validateTotpSecret } from '../validation/totp-validator.js';
|
||||
import { createCryptoError, createGenericError } from '../utils/error-formatter.js';
|
||||
|
||||
@@ -30,16 +30,13 @@ export const GenerateTotpInputSchema = z.object({
|
||||
.describe('Base32-encoded TOTP secret'),
|
||||
});
|
||||
|
||||
export type GenerateTotpInput = z.infer<typeof GenerateTotpInputSchema>;
|
||||
|
||||
/**
|
||||
* Generate HOTP code (RFC 4226)
|
||||
* Ported from generate-totp-standalone.mjs (lines 74-99)
|
||||
*
|
||||
* @param {string} secret - Base32-encoded secret
|
||||
* @param {number} counter - Counter value
|
||||
* @param {number} [digits=6] - Number of digits in OTP
|
||||
* @returns {string} OTP code
|
||||
*/
|
||||
function generateHOTP(secret, counter, digits = 6) {
|
||||
function generateHOTP(secret: string, counter: number, digits: number = 6): string {
|
||||
const key = base32Decode(secret);
|
||||
|
||||
// Convert counter to 8-byte buffer (big-endian)
|
||||
@@ -52,12 +49,12 @@ function generateHOTP(secret, counter, digits = 6) {
|
||||
const hash = hmac.digest();
|
||||
|
||||
// Dynamic truncation
|
||||
const offset = hash[hash.length - 1] & 0x0f;
|
||||
const offset = hash[hash.length - 1]! & 0x0f;
|
||||
const code =
|
||||
((hash[offset] & 0x7f) << 24) |
|
||||
((hash[offset + 1] & 0xff) << 16) |
|
||||
((hash[offset + 2] & 0xff) << 8) |
|
||||
(hash[offset + 3] & 0xff);
|
||||
((hash[offset]! & 0x7f) << 24) |
|
||||
((hash[offset + 1]! & 0xff) << 16) |
|
||||
((hash[offset + 2]! & 0xff) << 8) |
|
||||
(hash[offset + 3]! & 0xff);
|
||||
|
||||
// Generate digits
|
||||
const otp = (code % Math.pow(10, digits)).toString().padStart(digits, '0');
|
||||
@@ -67,13 +64,8 @@ function generateHOTP(secret, counter, digits = 6) {
|
||||
/**
|
||||
* Generate TOTP code (RFC 6238)
|
||||
* Ported from generate-totp-standalone.mjs (lines 101-106)
|
||||
*
|
||||
* @param {string} secret - Base32-encoded secret
|
||||
* @param {number} [timeStep=30] - Time step in seconds
|
||||
* @param {number} [digits=6] - Number of digits in OTP
|
||||
* @returns {string} OTP code
|
||||
*/
|
||||
function generateTOTP(secret, timeStep = 30, digits = 6) {
|
||||
function generateTOTP(secret: string, timeStep: number = 30, digits: number = 6): string {
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const counter = Math.floor(currentTime / timeStep);
|
||||
return generateHOTP(secret, counter, digits);
|
||||
@@ -81,23 +73,16 @@ function generateTOTP(secret, timeStep = 30, digits = 6) {
|
||||
|
||||
/**
|
||||
* Get seconds until TOTP code expires
|
||||
*
|
||||
* @param {number} [timeStep=30] - Time step in seconds
|
||||
* @returns {number} Seconds until expiration
|
||||
*/
|
||||
function getSecondsUntilExpiration(timeStep = 30) {
|
||||
function getSecondsUntilExpiration(timeStep: number = 30): number {
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return timeStep - (currentTime % timeStep);
|
||||
}
|
||||
|
||||
/**
|
||||
* generate_totp tool implementation
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {string} args.secret - Base32-encoded TOTP secret
|
||||
* @returns {Promise<Object>} Tool result
|
||||
*/
|
||||
export async function generateTotp(args) {
|
||||
export async function generateTotp(args: GenerateTotpInput): Promise<ToolResult> {
|
||||
try {
|
||||
const { secret } = args;
|
||||
|
||||
@@ -110,7 +95,7 @@ export async function generateTotp(args) {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Success response
|
||||
const successResponse = {
|
||||
const successResponse: GenerateTotpResponse = {
|
||||
status: 'success',
|
||||
message: 'TOTP code generated successfully',
|
||||
totpCode,
|
||||
@@ -14,7 +14,7 @@
|
||||
import { tool } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { z } from 'zod';
|
||||
import { DeliverableType, DELIVERABLE_FILENAMES, isQueueType } from '../types/deliverables.js';
|
||||
import { createToolResult } from '../types/tool-responses.js';
|
||||
import { createToolResult, type ToolResult, type SaveDeliverableResponse } from '../types/tool-responses.js';
|
||||
import { validateQueueJson } from '../validation/queue-validator.js';
|
||||
import { saveDeliverableFile } from '../utils/file-operations.js';
|
||||
import { createValidationError, createGenericError } from '../utils/error-formatter.js';
|
||||
@@ -27,15 +27,12 @@ export const SaveDeliverableInputSchema = z.object({
|
||||
content: z.string().min(1).describe('File content (markdown for analysis/evidence, JSON for queues)'),
|
||||
});
|
||||
|
||||
export type SaveDeliverableInput = z.infer<typeof SaveDeliverableInputSchema>;
|
||||
|
||||
/**
|
||||
* save_deliverable tool implementation
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {string} args.deliverable_type - Type of deliverable to save
|
||||
* @param {string} args.content - File content
|
||||
* @returns {Promise<Object>} Tool result
|
||||
*/
|
||||
export async function saveDeliverable(args) {
|
||||
export async function saveDeliverable(args: SaveDeliverableInput): Promise<ToolResult> {
|
||||
try {
|
||||
const { deliverable_type, content } = args;
|
||||
|
||||
@@ -44,7 +41,7 @@ export async function saveDeliverable(args) {
|
||||
const queueValidation = validateQueueJson(content);
|
||||
if (!queueValidation.valid) {
|
||||
const errorResponse = createValidationError(
|
||||
queueValidation.message,
|
||||
queueValidation.message ?? 'Invalid queue JSON',
|
||||
true,
|
||||
{
|
||||
deliverableType: deliverable_type,
|
||||
@@ -60,7 +57,7 @@ export async function saveDeliverable(args) {
|
||||
const filepath = saveDeliverableFile(filename, content);
|
||||
|
||||
// Success response
|
||||
const successResponse = {
|
||||
const successResponse: SaveDeliverableResponse = {
|
||||
status: 'success',
|
||||
message: `Deliverable saved successfully: ${filename}`,
|
||||
filepath,
|
||||
@@ -11,63 +11,42 @@
|
||||
* Must match the exact mappings from tools/save_deliverable.js.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DeliverableType
|
||||
* @property {string} CODE_ANALYSIS
|
||||
* @property {string} RECON
|
||||
* @property {string} INJECTION_ANALYSIS
|
||||
* @property {string} INJECTION_QUEUE
|
||||
* @property {string} XSS_ANALYSIS
|
||||
* @property {string} XSS_QUEUE
|
||||
* @property {string} AUTH_ANALYSIS
|
||||
* @property {string} AUTH_QUEUE
|
||||
* @property {string} AUTHZ_ANALYSIS
|
||||
* @property {string} AUTHZ_QUEUE
|
||||
* @property {string} SSRF_ANALYSIS
|
||||
* @property {string} SSRF_QUEUE
|
||||
* @property {string} INJECTION_EVIDENCE
|
||||
* @property {string} XSS_EVIDENCE
|
||||
* @property {string} AUTH_EVIDENCE
|
||||
* @property {string} AUTHZ_EVIDENCE
|
||||
* @property {string} SSRF_EVIDENCE
|
||||
*/
|
||||
|
||||
export const DeliverableType = {
|
||||
export enum DeliverableType {
|
||||
// Pre-recon agent
|
||||
CODE_ANALYSIS: 'CODE_ANALYSIS',
|
||||
CODE_ANALYSIS = 'CODE_ANALYSIS',
|
||||
|
||||
// Recon agent
|
||||
RECON: 'RECON',
|
||||
RECON = 'RECON',
|
||||
|
||||
// Vulnerability analysis agents
|
||||
INJECTION_ANALYSIS: 'INJECTION_ANALYSIS',
|
||||
INJECTION_QUEUE: 'INJECTION_QUEUE',
|
||||
INJECTION_ANALYSIS = 'INJECTION_ANALYSIS',
|
||||
INJECTION_QUEUE = 'INJECTION_QUEUE',
|
||||
|
||||
XSS_ANALYSIS: 'XSS_ANALYSIS',
|
||||
XSS_QUEUE: 'XSS_QUEUE',
|
||||
XSS_ANALYSIS = 'XSS_ANALYSIS',
|
||||
XSS_QUEUE = 'XSS_QUEUE',
|
||||
|
||||
AUTH_ANALYSIS: 'AUTH_ANALYSIS',
|
||||
AUTH_QUEUE: 'AUTH_QUEUE',
|
||||
AUTH_ANALYSIS = 'AUTH_ANALYSIS',
|
||||
AUTH_QUEUE = 'AUTH_QUEUE',
|
||||
|
||||
AUTHZ_ANALYSIS: 'AUTHZ_ANALYSIS',
|
||||
AUTHZ_QUEUE: 'AUTHZ_QUEUE',
|
||||
AUTHZ_ANALYSIS = 'AUTHZ_ANALYSIS',
|
||||
AUTHZ_QUEUE = 'AUTHZ_QUEUE',
|
||||
|
||||
SSRF_ANALYSIS: 'SSRF_ANALYSIS',
|
||||
SSRF_QUEUE: 'SSRF_QUEUE',
|
||||
SSRF_ANALYSIS = 'SSRF_ANALYSIS',
|
||||
SSRF_QUEUE = 'SSRF_QUEUE',
|
||||
|
||||
// Exploitation agents
|
||||
INJECTION_EVIDENCE: 'INJECTION_EVIDENCE',
|
||||
XSS_EVIDENCE: 'XSS_EVIDENCE',
|
||||
AUTH_EVIDENCE: 'AUTH_EVIDENCE',
|
||||
AUTHZ_EVIDENCE: 'AUTHZ_EVIDENCE',
|
||||
SSRF_EVIDENCE: 'SSRF_EVIDENCE',
|
||||
};
|
||||
INJECTION_EVIDENCE = 'INJECTION_EVIDENCE',
|
||||
XSS_EVIDENCE = 'XSS_EVIDENCE',
|
||||
AUTH_EVIDENCE = 'AUTH_EVIDENCE',
|
||||
AUTHZ_EVIDENCE = 'AUTHZ_EVIDENCE',
|
||||
SSRF_EVIDENCE = 'SSRF_EVIDENCE',
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-coded filename mappings from agent prompts
|
||||
* Must match tools/save_deliverable.js exactly
|
||||
*/
|
||||
export const DELIVERABLE_FILENAMES = {
|
||||
export const DELIVERABLE_FILENAMES: Record<DeliverableType, string> = {
|
||||
[DeliverableType.CODE_ANALYSIS]: 'code_analysis_deliverable.md',
|
||||
[DeliverableType.RECON]: 'recon_deliverable.md',
|
||||
[DeliverableType.INJECTION_ANALYSIS]: 'injection_analysis_deliverable.md',
|
||||
@@ -90,7 +69,7 @@ export const DELIVERABLE_FILENAMES = {
|
||||
/**
|
||||
* Queue types that require JSON validation
|
||||
*/
|
||||
export const QUEUE_TYPES = [
|
||||
export const QUEUE_TYPES: DeliverableType[] = [
|
||||
DeliverableType.INJECTION_QUEUE,
|
||||
DeliverableType.XSS_QUEUE,
|
||||
DeliverableType.AUTH_QUEUE,
|
||||
@@ -100,14 +79,18 @@ export const QUEUE_TYPES = [
|
||||
|
||||
/**
|
||||
* Type guard to check if a deliverable type is a queue
|
||||
* @param {string} type - Deliverable type to check
|
||||
* @returns {boolean} True if the type is a queue type
|
||||
*/
|
||||
export function isQueueType(type) {
|
||||
return QUEUE_TYPES.includes(type);
|
||||
export function isQueueType(type: string): boolean {
|
||||
return QUEUE_TYPES.includes(type as DeliverableType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} VulnerabilityQueue
|
||||
* @property {Array<Object>} vulnerabilities - Array of vulnerability objects
|
||||
* Vulnerability queue structure
|
||||
*/
|
||||
export interface VulnerabilityQueue {
|
||||
vulnerabilities: VulnerabilityItem[];
|
||||
}
|
||||
|
||||
export interface VulnerabilityItem {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -1,64 +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.
|
||||
|
||||
/**
|
||||
* Tool Response Type Definitions
|
||||
*
|
||||
* Defines structured response formats for MCP tools to ensure
|
||||
* consistent error handling and success reporting.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ErrorResponse
|
||||
* @property {'error'} status
|
||||
* @property {string} message
|
||||
* @property {string} errorType - ValidationError, FileSystemError, CryptoError, etc.
|
||||
* @property {boolean} retryable
|
||||
* @property {Record<string, unknown>} [context]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SuccessResponse
|
||||
* @property {'success'} status
|
||||
* @property {string} message
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SaveDeliverableResponse
|
||||
* @property {'success'} status
|
||||
* @property {string} message
|
||||
* @property {string} filepath
|
||||
* @property {string} deliverableType
|
||||
* @property {boolean} validated - true if queue JSON was validated
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} GenerateTotpResponse
|
||||
* @property {'success'} status
|
||||
* @property {string} message
|
||||
* @property {string} totpCode
|
||||
* @property {string} timestamp
|
||||
* @property {number} expiresIn - seconds until expiration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper to create tool result from response
|
||||
* MCP tools should return this format
|
||||
*
|
||||
* @param {ErrorResponse | SaveDeliverableResponse | GenerateTotpResponse} response
|
||||
* @returns {{ content: Array<{ type: string; text: string }>; isError: boolean }}
|
||||
*/
|
||||
export function createToolResult(response) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2),
|
||||
},
|
||||
],
|
||||
isError: response.status === 'error',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Tool Response Type Definitions
|
||||
*
|
||||
* Defines structured response formats for MCP tools to ensure
|
||||
* consistent error handling and success reporting.
|
||||
*/
|
||||
|
||||
export interface ErrorResponse {
|
||||
status: 'error';
|
||||
message: string;
|
||||
errorType: string; // ValidationError, FileSystemError, CryptoError, etc.
|
||||
retryable: boolean;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SuccessResponse {
|
||||
status: 'success';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SaveDeliverableResponse {
|
||||
status: 'success';
|
||||
message: string;
|
||||
filepath: string;
|
||||
deliverableType: string;
|
||||
validated: boolean; // true if queue JSON was validated
|
||||
}
|
||||
|
||||
export interface GenerateTotpResponse {
|
||||
status: 'success';
|
||||
message: string;
|
||||
totpCode: string;
|
||||
timestamp: string;
|
||||
expiresIn: number; // seconds until expiration
|
||||
}
|
||||
|
||||
export type ToolResponse =
|
||||
| ErrorResponse
|
||||
| SuccessResponse
|
||||
| SaveDeliverableResponse
|
||||
| GenerateTotpResponse;
|
||||
|
||||
export interface ToolResultContent {
|
||||
type: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
content: ToolResultContent[];
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create tool result from response
|
||||
* MCP tools should return this format
|
||||
*/
|
||||
export function createToolResult(response: ToolResponse): ToolResult {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response, null, 2),
|
||||
},
|
||||
],
|
||||
isError: response.status === 'error',
|
||||
};
|
||||
}
|
||||
@@ -10,60 +10,50 @@
|
||||
* Helper functions for creating structured error responses.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ErrorResponse
|
||||
* @property {'error'} status
|
||||
* @property {string} message
|
||||
* @property {string} errorType
|
||||
* @property {boolean} retryable
|
||||
* @property {Record<string, unknown>} [context]
|
||||
*/
|
||||
import type { ErrorResponse } from '../types/tool-responses.js';
|
||||
|
||||
/**
|
||||
* Create a validation error response
|
||||
*
|
||||
* @param {string} message
|
||||
* @param {boolean} [retryable=true]
|
||||
* @param {Record<string, unknown>} [context]
|
||||
* @returns {ErrorResponse}
|
||||
*/
|
||||
export function createValidationError(message, retryable = true, context) {
|
||||
export function createValidationError(
|
||||
message: string,
|
||||
retryable: boolean = true,
|
||||
context?: Record<string, unknown>
|
||||
): ErrorResponse {
|
||||
return {
|
||||
status: 'error',
|
||||
message,
|
||||
errorType: 'ValidationError',
|
||||
retryable,
|
||||
context,
|
||||
...(context !== undefined && { context }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a crypto error response
|
||||
*
|
||||
* @param {string} message
|
||||
* @param {boolean} [retryable=false]
|
||||
* @param {Record<string, unknown>} [context]
|
||||
* @returns {ErrorResponse}
|
||||
*/
|
||||
export function createCryptoError(message, retryable = false, context) {
|
||||
export function createCryptoError(
|
||||
message: string,
|
||||
retryable: boolean = false,
|
||||
context?: Record<string, unknown>
|
||||
): ErrorResponse {
|
||||
return {
|
||||
status: 'error',
|
||||
message,
|
||||
errorType: 'CryptoError',
|
||||
retryable,
|
||||
context,
|
||||
...(context !== undefined && { context }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generic error response
|
||||
*
|
||||
* @param {unknown} error
|
||||
* @param {boolean} [retryable=false]
|
||||
* @param {Record<string, unknown>} [context]
|
||||
* @returns {ErrorResponse}
|
||||
*/
|
||||
export function createGenericError(error, retryable = false, context) {
|
||||
export function createGenericError(
|
||||
error: unknown,
|
||||
retryable: boolean = false,
|
||||
context?: Record<string, unknown>
|
||||
): ErrorResponse {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const errorType = error instanceof Error ? error.constructor.name : 'UnknownError';
|
||||
|
||||
@@ -72,6 +62,6 @@ export function createGenericError(error, retryable = false, context) {
|
||||
message,
|
||||
errorType,
|
||||
retryable,
|
||||
context,
|
||||
...(context !== undefined && { context }),
|
||||
};
|
||||
}
|
||||
@@ -14,14 +14,14 @@
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
declare global {
|
||||
var __SHANNON_TARGET_DIR: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save deliverable file to deliverables/ directory
|
||||
*
|
||||
* @param {string} filename - Name of the file to save
|
||||
* @param {string} content - Content to write to the file
|
||||
* @returns {string} Full path to the saved file
|
||||
*/
|
||||
export function saveDeliverableFile(filename, content) {
|
||||
export function saveDeliverableFile(filename: string, content: string): string {
|
||||
// Use target directory from global context (set by createShannonHelperServer)
|
||||
const targetDir = global.__SHANNON_TARGET_DIR || process.cwd();
|
||||
const deliverablesDir = join(targetDir, 'deliverables');
|
||||
@@ -30,7 +30,7 @@ export function saveDeliverableFile(filename, content) {
|
||||
// Ensure deliverables directory exists
|
||||
try {
|
||||
mkdirSync(deliverablesDir, { recursive: true });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Directory might already exist, ignore
|
||||
}
|
||||
|
||||
+22
-14
@@ -11,33 +11,41 @@
|
||||
* Ported from tools/save_deliverable.js (lines 56-75).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ValidationResult
|
||||
* @property {boolean} valid
|
||||
* @property {string} [message]
|
||||
* @property {Object} [data]
|
||||
*/
|
||||
import type { VulnerabilityQueue } from '../types/deliverables.js';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
data?: VulnerabilityQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JSON structure for queue files
|
||||
* Queue files must have a 'vulnerabilities' array
|
||||
*
|
||||
* @param {string} content - JSON string to validate
|
||||
* @returns {ValidationResult} ValidationResult with valid flag, optional error message, and parsed data
|
||||
*/
|
||||
export function validateQueueJson(content) {
|
||||
export function validateQueueJson(content: string): ValidationResult {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
|
||||
// Type guard for the parsed result
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Invalid queue structure: Expected an object. Got: ${typeof parsed}`,
|
||||
};
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// Queue files must have a 'vulnerabilities' array
|
||||
if (!parsed.vulnerabilities) {
|
||||
if (!('vulnerabilities' in obj)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Invalid queue structure: Missing 'vulnerabilities' property. Expected: {"vulnerabilities": [...]}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed.vulnerabilities)) {
|
||||
if (!Array.isArray(obj.vulnerabilities)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Invalid queue structure: 'vulnerabilities' must be an array. Expected: {"vulnerabilities": [...]}`,
|
||||
@@ -46,7 +54,7 @@ export function validateQueueJson(content) {
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
data: parsed,
|
||||
data: parsed as VulnerabilityQueue,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
+4
-8
@@ -14,11 +14,8 @@
|
||||
/**
|
||||
* Base32 decode function
|
||||
* Ported from generate-totp-standalone.mjs
|
||||
*
|
||||
* @param {string} encoded - Base32 encoded string
|
||||
* @returns {Buffer} Buffer containing decoded bytes
|
||||
*/
|
||||
export function base32Decode(encoded) {
|
||||
export function base32Decode(encoded: string): Buffer {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const cleanInput = encoded.toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||
|
||||
@@ -26,7 +23,7 @@ export function base32Decode(encoded) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
const output = [];
|
||||
const output: number[] = [];
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
|
||||
@@ -52,10 +49,9 @@ export function base32Decode(encoded) {
|
||||
* Validate TOTP secret
|
||||
* Must be base32-encoded string
|
||||
*
|
||||
* @param {string} secret - Secret to validate
|
||||
* @returns {boolean} true if valid, throws Error if invalid
|
||||
* @returns true if valid, throws Error if invalid
|
||||
*/
|
||||
export function validateTotpSecret(secret) {
|
||||
export function validateTotpSecret(secret: string): boolean {
|
||||
if (!secret || secret.length === 0) {
|
||||
throw new Error('TOTP secret cannot be empty');
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
// Visit https://aka.ms/tsconfig to read more about this file
|
||||
"compilerOptions": {
|
||||
// File Layout
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
|
||||
// Environment Settings
|
||||
// See also https://aka.ms/tsconfig/module
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
|
||||
"target": "es2022",
|
||||
"lib": ["es2022"],
|
||||
|
||||
"types": ["node"],
|
||||
// For nodejs:
|
||||
// "lib": ["esnext"],
|
||||
// "types": ["node"],
|
||||
// and npm install -D @types/node
|
||||
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmitOnError": true,
|
||||
|
||||
// Other Outputs
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
// Stricter Typechecking Options
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
||||
// Style Options
|
||||
// "noImplicitReturns": true,
|
||||
// "noImplicitOverride": true,
|
||||
// "noUnusedLocals": true,
|
||||
// "noUnusedParameters": true,
|
||||
// "noFallthroughCasesInSwitch": true,
|
||||
// "noPropertyAccessFromIndexSignature": true,
|
||||
|
||||
// Recommended Options
|
||||
"strict": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Generated
+44
-1
@@ -21,7 +21,12 @@
|
||||
"zx": "^8.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"shannon": "shannon.mjs"
|
||||
"shannon": "dist/shannon.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.0.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
@@ -253,6 +258,23 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
|
||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tinycolor2": {
|
||||
"version": "1.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz",
|
||||
@@ -615,6 +637,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/widest-line": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
|
||||
|
||||
+9
-3
@@ -2,9 +2,10 @@
|
||||
"name": "shannon",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "shannon.mjs",
|
||||
"main": "./dist/shannon.js",
|
||||
"scripts": {
|
||||
"start": "./shannon.mjs"
|
||||
"build": "tsc",
|
||||
"start": "node ./dist/shannon.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
|
||||
@@ -20,6 +21,11 @@
|
||||
"zx": "^8.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"shannon": "./shannon.mjs"
|
||||
"shannon": "./dist/shannon.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.0.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { $, fs, path } from 'zx';
|
||||
import chalk from 'chalk';
|
||||
import chalk, { type ChalkInstance } from 'chalk';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
@@ -19,18 +19,48 @@ import { AGENT_VALIDATORS, MCP_AGENT_MAPPING } from '../constants.js';
|
||||
import { filterJsonToolCalls, getAgentPrefix } from '../utils/output-formatter.js';
|
||||
import { generateSessionLogPath } from '../session-manager.js';
|
||||
import { AuditSession } from '../audit/index.js';
|
||||
import { createShannonHelperServer } from '../../mcp-server/src/index.js';
|
||||
import { createShannonHelperServer } from '../../mcp-server/dist/index.js';
|
||||
import type { SessionMetadata } from '../audit/utils.js';
|
||||
import type { PromptName } from '../types/index.js';
|
||||
|
||||
// Extend global for loader flag
|
||||
declare global {
|
||||
var SHANNON_DISABLE_LOADER: boolean | undefined;
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Result types
|
||||
interface ClaudePromptResult {
|
||||
result?: string | null;
|
||||
success: boolean;
|
||||
duration: number;
|
||||
turns?: number;
|
||||
cost: number;
|
||||
partialCost?: number;
|
||||
apiErrorDetected?: boolean;
|
||||
logFile?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
prompt?: string;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
// MCP Server types
|
||||
interface StdioMcpServer {
|
||||
type: 'stdio';
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
type McpServer = ReturnType<typeof createShannonHelperServer> | StdioMcpServer;
|
||||
|
||||
/**
|
||||
* Convert agent name to prompt name for MCP_AGENT_MAPPING lookup
|
||||
*
|
||||
* @param {string} agentName - Agent name (e.g., 'xss-vuln', 'injection-exploit')
|
||||
* @returns {string} Prompt name (e.g., 'vuln-xss', 'exploit-injection')
|
||||
*/
|
||||
function agentNameToPromptName(agentName) {
|
||||
function agentNameToPromptName(agentName: string): PromptName {
|
||||
// Special cases
|
||||
if (agentName === 'pre-recon') return 'pre-recon-code';
|
||||
if (agentName === 'report') return 'report-executive';
|
||||
@@ -39,21 +69,25 @@ function agentNameToPromptName(agentName) {
|
||||
// Pattern: {type}-vuln → vuln-{type}
|
||||
const vulnMatch = agentName.match(/^(.+)-vuln$/);
|
||||
if (vulnMatch) {
|
||||
return `vuln-${vulnMatch[1]}`;
|
||||
return `vuln-${vulnMatch[1]}` as PromptName;
|
||||
}
|
||||
|
||||
// Pattern: {type}-exploit → exploit-{type}
|
||||
const exploitMatch = agentName.match(/^(.+)-exploit$/);
|
||||
if (exploitMatch) {
|
||||
return `exploit-${exploitMatch[1]}`;
|
||||
return `exploit-${exploitMatch[1]}` as PromptName;
|
||||
}
|
||||
|
||||
// Default: return as-is
|
||||
return agentName;
|
||||
return agentName as PromptName;
|
||||
}
|
||||
|
||||
// Simplified validation using direct agent name mapping
|
||||
async function validateAgentOutput(result, agentName, sourceDir) {
|
||||
async function validateAgentOutput(
|
||||
result: ClaudePromptResult,
|
||||
agentName: string | null,
|
||||
sourceDir: string
|
||||
): Promise<boolean> {
|
||||
console.log(chalk.blue(` 🔍 Validating ${agentName} agent output`));
|
||||
|
||||
try {
|
||||
@@ -64,7 +98,7 @@ async function validateAgentOutput(result, agentName, sourceDir) {
|
||||
}
|
||||
|
||||
// Get validator function for this agent
|
||||
const validator = AGENT_VALIDATORS[agentName];
|
||||
const validator = agentName ? AGENT_VALIDATORS[agentName as keyof typeof AGENT_VALIDATORS] : undefined;
|
||||
|
||||
if (!validator) {
|
||||
console.log(chalk.yellow(` ⚠️ No validator found for agent "${agentName}" - assuming success`));
|
||||
@@ -87,18 +121,26 @@ async function validateAgentOutput(result, agentName, sourceDir) {
|
||||
return validationResult;
|
||||
|
||||
} catch (error) {
|
||||
console.log(chalk.red(` ❌ Validation failed with error: ${error.message}`));
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.red(` ❌ Validation failed with error: ${errMsg}`));
|
||||
return false; // Assume invalid on validation error
|
||||
}
|
||||
}
|
||||
|
||||
// Pure function: Run Claude Code with SDK - Maximum Autonomy
|
||||
// WARNING: This is a low-level function. Use runClaudePromptWithRetry() for agent execution to ensure:
|
||||
// - Retry logic and error handling
|
||||
// - Output validation
|
||||
// - Prompt snapshotting for debugging
|
||||
// - Git checkpoint/rollback safety
|
||||
async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context = '', description = 'Claude analysis', agentName = null, colorFn = chalk.cyan, sessionMetadata = null, auditSession = null, attemptNumber = 1) {
|
||||
// WARNING: This is a low-level function. Use runClaudePromptWithRetry() for agent execution
|
||||
async function runClaudePrompt(
|
||||
prompt: string,
|
||||
sourceDir: string,
|
||||
_allowedTools: string = 'Read',
|
||||
context: string = '',
|
||||
description: string = 'Claude analysis',
|
||||
agentName: string | null = null,
|
||||
colorFn: ChalkInstance = chalk.cyan,
|
||||
sessionMetadata: SessionMetadata | null = null,
|
||||
auditSession: AuditSession | null = null,
|
||||
attemptNumber: number = 1
|
||||
): Promise<ClaudePromptResult> {
|
||||
const timer = new Timer(`agent-${description.toLowerCase().replace(/\s+/g, '-')}`);
|
||||
const fullPrompt = context ? `${context}\n\n${prompt}` : prompt;
|
||||
let totalCost = 0;
|
||||
@@ -116,7 +158,7 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
const statusManager = null;
|
||||
|
||||
// Setup progress indicator for clean output agents (unless disabled via flag)
|
||||
let progressIndicator = null;
|
||||
let progressIndicator: ProgressIndicator | null = null;
|
||||
if (useCleanOutput && !global.SHANNON_DISABLE_LOADER) {
|
||||
const agentType = description.includes('Pre-recon') ? 'pre-reconnaissance' :
|
||||
description.includes('Recon') ? 'reconnaissance' :
|
||||
@@ -125,13 +167,12 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
}
|
||||
|
||||
// NOTE: Logging now handled by AuditSession (append-only, crash-safe)
|
||||
// Legacy log path generation kept for compatibility
|
||||
let logFilePath = null;
|
||||
let logFilePath: string | null = null;
|
||||
if (sessionMetadata && sessionMetadata.webUrl && sessionMetadata.id) {
|
||||
const timestamp = new Date().toISOString().replace(/T/, '_').replace(/[:.]/g, '-').slice(0, 19);
|
||||
const agentName = description.toLowerCase().replace(/\s+/g, '-');
|
||||
const agentKey = description.toLowerCase().replace(/\s+/g, '-');
|
||||
const logDir = generateSessionLogPath(sessionMetadata.webUrl, sessionMetadata.id);
|
||||
logFilePath = path.join(logDir, `${timestamp}_${agentName}_attempt-${attemptNumber}.log`);
|
||||
logFilePath = path.join(logDir, `${timestamp}_${agentKey}_attempt-${attemptNumber}.log`);
|
||||
} else {
|
||||
console.log(chalk.blue(` 🤖 Running Claude Code: ${description}...`));
|
||||
}
|
||||
@@ -144,11 +185,10 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
const shannonHelperServer = createShannonHelperServer(sourceDir);
|
||||
|
||||
// Look up agent's assigned Playwright MCP server
|
||||
// Convert agent name (e.g., 'xss-vuln') to prompt name (e.g., 'vuln-xss')
|
||||
let playwrightMcpName = null;
|
||||
let playwrightMcpName: string | null = null;
|
||||
if (agentName) {
|
||||
const promptName = agentNameToPromptName(agentName);
|
||||
playwrightMcpName = MCP_AGENT_MAPPING[promptName];
|
||||
playwrightMcpName = MCP_AGENT_MAPPING[promptName as keyof typeof MCP_AGENT_MAPPING] || null;
|
||||
|
||||
if (playwrightMcpName) {
|
||||
console.log(chalk.gray(` 🎭 Assigned ${agentName} → ${playwrightMcpName}`));
|
||||
@@ -156,7 +196,7 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
}
|
||||
|
||||
// Configure MCP servers: shannon-helper (SDK) + playwright-agentN (stdio)
|
||||
const mcpServers = {
|
||||
const mcpServers: Record<string, McpServer> = {
|
||||
'shannon-helper': shannonHelperServer,
|
||||
};
|
||||
|
||||
@@ -168,7 +208,7 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
const isDocker = process.env.SHANNON_DOCKER === 'true';
|
||||
|
||||
// Build args array - conditionally add --executable-path for Docker
|
||||
const mcpArgs = [
|
||||
const mcpArgs: string[] = [
|
||||
'@playwright/mcp@latest',
|
||||
'--isolated',
|
||||
'--user-data-dir', userDataDir,
|
||||
@@ -180,15 +220,20 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
mcpArgs.push('--browser', 'chromium');
|
||||
}
|
||||
|
||||
// Filter out undefined env values for type safety
|
||||
const envVars: Record<string, string> = Object.fromEntries(
|
||||
Object.entries({
|
||||
...process.env,
|
||||
PLAYWRIGHT_HEADLESS: 'true',
|
||||
...(isDocker && { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' }),
|
||||
}).filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||
);
|
||||
|
||||
mcpServers[playwrightMcpName] = {
|
||||
type: 'stdio',
|
||||
type: 'stdio' as const,
|
||||
command: 'npx',
|
||||
args: mcpArgs,
|
||||
env: {
|
||||
...process.env,
|
||||
PLAYWRIGHT_HEADLESS: 'true', // Ensure headless mode for security and CI compatibility
|
||||
...(isDocker && { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' }), // Only skip in Docker
|
||||
},
|
||||
env: envVars,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,7 +241,7 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
model: 'claude-sonnet-4-5-20250929', // Use latest Claude 4.5 Sonnet
|
||||
maxTurns: 10_000, // Maximum turns for autonomous work
|
||||
cwd: sourceDir, // Set working directory using SDK option
|
||||
permissionMode: 'bypassPermissions', // Bypass all permission checks for pentesting
|
||||
permissionMode: 'bypassPermissions' as const, // Bypass all permission checks for pentesting
|
||||
mcpServers,
|
||||
};
|
||||
|
||||
@@ -205,8 +250,8 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
console.log(chalk.gray(` SDK Options: maxTurns=${options.maxTurns}, cwd=${sourceDir}, permissions=BYPASS`));
|
||||
}
|
||||
|
||||
let result = null;
|
||||
let messages = [];
|
||||
let result: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let apiErrorDetected = false;
|
||||
|
||||
// Start progress indicator for clean output agents
|
||||
@@ -214,15 +259,11 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
progressIndicator.start();
|
||||
}
|
||||
|
||||
|
||||
let messageCount = 0;
|
||||
let lastHeartbeat = Date.now();
|
||||
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
try {
|
||||
for await (const message of query({ prompt: fullPrompt, options })) {
|
||||
messageCount++;
|
||||
|
||||
// Periodic heartbeat for long-running agents (only when loader is disabled)
|
||||
const now = Date.now();
|
||||
if (global.SHANNON_DISABLE_LOADER && now - lastHeartbeat > HEARTBEAT_INTERVAL) {
|
||||
@@ -230,181 +271,186 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
lastHeartbeat = now;
|
||||
}
|
||||
|
||||
if (message.type === "assistant") {
|
||||
turnCount++;
|
||||
if (message.type === "assistant") {
|
||||
turnCount++;
|
||||
|
||||
const content = Array.isArray(message.message.content)
|
||||
? message.message.content.map(c => c.text || JSON.stringify(c)).join('\n')
|
||||
: message.message.content;
|
||||
const messageContent = message.message as { content: unknown };
|
||||
const content = Array.isArray(messageContent.content)
|
||||
? messageContent.content.map((c: { text?: string }) => c.text || JSON.stringify(c)).join('\n')
|
||||
: String(messageContent.content);
|
||||
|
||||
if (statusManager) {
|
||||
// Smart status updates for parallel execution
|
||||
const toolUse = statusManager.parseToolUse(content);
|
||||
statusManager.updateAgentStatus(description, {
|
||||
tool_use: toolUse,
|
||||
assistant_text: content,
|
||||
turnCount
|
||||
});
|
||||
} else if (useCleanOutput) {
|
||||
// Clean output for all agents: filter JSON tool calls but show meaningful text
|
||||
const cleanedContent = filterJsonToolCalls(content);
|
||||
if (cleanedContent.trim()) {
|
||||
// Temporarily stop progress indicator to show output
|
||||
if (progressIndicator) {
|
||||
progressIndicator.stop();
|
||||
}
|
||||
if (statusManager) {
|
||||
// Smart status updates for parallel execution - disabled
|
||||
} else if (useCleanOutput) {
|
||||
// Clean output for all agents: filter JSON tool calls but show meaningful text
|
||||
const cleanedContent = filterJsonToolCalls(content);
|
||||
if (cleanedContent.trim()) {
|
||||
// Temporarily stop progress indicator to show output
|
||||
if (progressIndicator) {
|
||||
progressIndicator.stop();
|
||||
}
|
||||
|
||||
if (isParallelExecution) {
|
||||
// Compact output for parallel agents with prefixes
|
||||
const prefix = getAgentPrefix(description);
|
||||
console.log(colorFn(`${prefix} ${cleanedContent}`));
|
||||
} else {
|
||||
// Full turn output for single agents
|
||||
console.log(colorFn(`\n 🤖 Turn ${turnCount} (${description}):`))
|
||||
console.log(colorFn(` ${cleanedContent}`));
|
||||
}
|
||||
|
||||
// Restart progress indicator after output
|
||||
if (progressIndicator) {
|
||||
progressIndicator.start();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Full streaming output - show complete messages with specialist color
|
||||
console.log(colorFn(`\n 🤖 Turn ${turnCount} (${description}):`))
|
||||
console.log(colorFn(` ${content}`));
|
||||
}
|
||||
|
||||
// Log to audit system (crash-safe, append-only)
|
||||
if (auditSession) {
|
||||
await auditSession.logEvent('llm_response', {
|
||||
turn: turnCount,
|
||||
content,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
messages.push(content);
|
||||
|
||||
// Check for API error patterns in assistant message content
|
||||
if (content && typeof content === 'string') {
|
||||
const lowerContent = content.toLowerCase();
|
||||
if (lowerContent.includes('session limit reached')) {
|
||||
throw new PentestError('Session limit reached', 'billing', false);
|
||||
}
|
||||
if (lowerContent.includes('api error') || lowerContent.includes('terminated')) {
|
||||
apiErrorDetected = true;
|
||||
console.log(chalk.red(` ⚠️ API Error detected in assistant response: ${content.trim()}`));
|
||||
}
|
||||
}
|
||||
|
||||
} else if (message.type === "system" && message.subtype === "init") {
|
||||
// Show useful system info only for verbose agents
|
||||
if (!useCleanOutput) {
|
||||
console.log(chalk.blue(` ℹ️ Model: ${message.model}, Permission: ${message.permissionMode}`));
|
||||
if (message.mcp_servers && message.mcp_servers.length > 0) {
|
||||
const mcpStatus = message.mcp_servers.map(s => `${s.name}(${s.status})`).join(', ');
|
||||
console.log(chalk.blue(` 📦 MCP: ${mcpStatus}`));
|
||||
}
|
||||
}
|
||||
|
||||
} else if (message.type === "user") {
|
||||
// Skip user messages (these are our own inputs echoed back)
|
||||
continue;
|
||||
|
||||
} else if (message.type === "tool_use") {
|
||||
console.log(chalk.yellow(`\n 🔧 Using Tool: ${message.name}`));
|
||||
if (message.input && Object.keys(message.input).length > 0) {
|
||||
console.log(chalk.gray(` Input: ${JSON.stringify(message.input, null, 2)}`));
|
||||
}
|
||||
|
||||
// Log tool start event
|
||||
if (auditSession) {
|
||||
await auditSession.logEvent('tool_start', {
|
||||
toolName: message.name,
|
||||
parameters: message.input,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} else if (message.type === "tool_result") {
|
||||
console.log(chalk.green(` ✅ Tool Result:`));
|
||||
if (message.content) {
|
||||
// Show tool results but truncate if too long
|
||||
const resultStr = typeof message.content === 'string' ? message.content : JSON.stringify(message.content, null, 2);
|
||||
if (resultStr.length > 500) {
|
||||
console.log(chalk.gray(` ${resultStr.slice(0, 500)}...\n [Result truncated - ${resultStr.length} total chars]`));
|
||||
} else {
|
||||
console.log(chalk.gray(` ${resultStr}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Log tool end event
|
||||
if (auditSession) {
|
||||
await auditSession.logEvent('tool_end', {
|
||||
result: message.content,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} else if (message.type === "result") {
|
||||
result = message.result;
|
||||
|
||||
if (!statusManager) {
|
||||
if (useCleanOutput) {
|
||||
// Clean completion output - just duration and cost
|
||||
console.log(chalk.magenta(`\n 🏁 COMPLETED:`));
|
||||
const cost = message.total_cost_usd || 0;
|
||||
console.log(chalk.gray(` ⏱️ Duration: ${(message.duration_ms/1000).toFixed(1)}s, Cost: $${cost.toFixed(4)}`));
|
||||
|
||||
if (message.subtype === "error_max_turns") {
|
||||
console.log(chalk.red(` ⚠️ Stopped: Hit maximum turns limit`));
|
||||
} else if (message.subtype === "error_during_execution") {
|
||||
console.log(chalk.red(` ❌ Stopped: Execution error`));
|
||||
}
|
||||
|
||||
if (message.permission_denials && message.permission_denials.length > 0) {
|
||||
console.log(chalk.yellow(` 🚫 ${message.permission_denials.length} permission denials`));
|
||||
}
|
||||
} else {
|
||||
// Full completion output for agents without clean output
|
||||
console.log(chalk.magenta(`\n 🏁 COMPLETED:`));
|
||||
const cost = message.total_cost_usd || 0;
|
||||
console.log(chalk.gray(` ⏱️ Duration: ${(message.duration_ms/1000).toFixed(1)}s, Cost: $${cost.toFixed(4)}`));
|
||||
|
||||
if (message.subtype === "error_max_turns") {
|
||||
console.log(chalk.red(` ⚠️ Stopped: Hit maximum turns limit`));
|
||||
} else if (message.subtype === "error_during_execution") {
|
||||
console.log(chalk.red(` ❌ Stopped: Execution error`));
|
||||
}
|
||||
|
||||
if (message.permission_denials && message.permission_denials.length > 0) {
|
||||
console.log(chalk.yellow(` 🚫 ${message.permission_denials.length} permission denials`));
|
||||
}
|
||||
|
||||
// Show result content (if it's reasonable length)
|
||||
if (result && typeof result === 'string') {
|
||||
if (result.length > 1000) {
|
||||
console.log(chalk.magenta(` 📄 ${result.slice(0, 1000)}... [${result.length} total chars]`));
|
||||
if (isParallelExecution) {
|
||||
// Compact output for parallel agents with prefixes
|
||||
const prefix = getAgentPrefix(description);
|
||||
console.log(colorFn(`${prefix} ${cleanedContent}`));
|
||||
} else {
|
||||
console.log(chalk.magenta(` 📄 ${result}`));
|
||||
// Full turn output for single agents
|
||||
console.log(colorFn(`\n 🤖 Turn ${turnCount} (${description}):`));
|
||||
console.log(colorFn(` ${cleanedContent}`));
|
||||
}
|
||||
|
||||
// Restart progress indicator after output
|
||||
if (progressIndicator) {
|
||||
progressIndicator.start();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Full streaming output - show complete messages with specialist color
|
||||
console.log(colorFn(`\n 🤖 Turn ${turnCount} (${description}):`));
|
||||
console.log(colorFn(` ${content}`));
|
||||
}
|
||||
|
||||
// Log to audit system (crash-safe, append-only)
|
||||
if (auditSession) {
|
||||
await auditSession.logEvent('llm_response', {
|
||||
turn: turnCount,
|
||||
content,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
messages.push(content);
|
||||
|
||||
// Check for API error patterns in assistant message content
|
||||
if (content && typeof content === 'string') {
|
||||
const lowerContent = content.toLowerCase();
|
||||
if (lowerContent.includes('session limit reached')) {
|
||||
throw new PentestError('Session limit reached', 'billing', false);
|
||||
}
|
||||
if (lowerContent.includes('api error') || lowerContent.includes('terminated')) {
|
||||
apiErrorDetected = true;
|
||||
console.log(chalk.red(` ⚠️ API Error detected in assistant response: ${content.trim()}`));
|
||||
}
|
||||
}
|
||||
|
||||
} else if (message.type === "system" && (message as { subtype?: string }).subtype === "init") {
|
||||
// Show useful system info only for verbose agents
|
||||
if (!useCleanOutput) {
|
||||
const initMsg = message as { model?: string; permissionMode?: string; mcp_servers?: Array<{ name: string; status: string }> };
|
||||
console.log(chalk.blue(` ℹ️ Model: ${initMsg.model}, Permission: ${initMsg.permissionMode}`));
|
||||
if (initMsg.mcp_servers && initMsg.mcp_servers.length > 0) {
|
||||
const mcpStatus = initMsg.mcp_servers.map(s => `${s.name}(${s.status})`).join(', ');
|
||||
console.log(chalk.blue(` 📦 MCP: ${mcpStatus}`));
|
||||
}
|
||||
}
|
||||
|
||||
} else if (message.type === "user") {
|
||||
// Skip user messages (these are our own inputs echoed back)
|
||||
continue;
|
||||
|
||||
} else if ((message.type as string) === "tool_use") {
|
||||
const toolMsg = message as unknown as { name: string; input?: Record<string, unknown> };
|
||||
console.log(chalk.yellow(`\n 🔧 Using Tool: ${toolMsg.name}`));
|
||||
if (toolMsg.input && Object.keys(toolMsg.input).length > 0) {
|
||||
console.log(chalk.gray(` Input: ${JSON.stringify(toolMsg.input, null, 2)}`));
|
||||
}
|
||||
|
||||
// Log tool start event
|
||||
if (auditSession) {
|
||||
await auditSession.logEvent('tool_start', {
|
||||
toolName: toolMsg.name,
|
||||
parameters: toolMsg.input,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} else if ((message.type as string) === "tool_result") {
|
||||
const resultMsg = message as unknown as { content?: unknown };
|
||||
console.log(chalk.green(` ✅ Tool Result:`));
|
||||
if (resultMsg.content) {
|
||||
// Show tool results but truncate if too long
|
||||
const resultStr = typeof resultMsg.content === 'string' ? resultMsg.content : JSON.stringify(resultMsg.content, null, 2);
|
||||
if (resultStr.length > 500) {
|
||||
console.log(chalk.gray(` ${resultStr.slice(0, 500)}...\n [Result truncated - ${resultStr.length} total chars]`));
|
||||
} else {
|
||||
console.log(chalk.gray(` ${resultStr}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Log tool end event
|
||||
if (auditSession) {
|
||||
await auditSession.logEvent('tool_end', {
|
||||
result: resultMsg.content,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} else if (message.type === "result") {
|
||||
const resultMessage = message as {
|
||||
result?: string;
|
||||
total_cost_usd?: number;
|
||||
duration_ms?: number;
|
||||
subtype?: string;
|
||||
permission_denials?: unknown[];
|
||||
};
|
||||
result = resultMessage.result || null;
|
||||
|
||||
if (!statusManager) {
|
||||
if (useCleanOutput) {
|
||||
// Clean completion output - just duration and cost
|
||||
console.log(chalk.magenta(`\n 🏁 COMPLETED:`));
|
||||
const cost = resultMessage.total_cost_usd || 0;
|
||||
console.log(chalk.gray(` ⏱️ Duration: ${((resultMessage.duration_ms || 0)/1000).toFixed(1)}s, Cost: $${cost.toFixed(4)}`));
|
||||
|
||||
if (resultMessage.subtype === "error_max_turns") {
|
||||
console.log(chalk.red(` ⚠️ Stopped: Hit maximum turns limit`));
|
||||
} else if (resultMessage.subtype === "error_during_execution") {
|
||||
console.log(chalk.red(` ❌ Stopped: Execution error`));
|
||||
}
|
||||
|
||||
if (resultMessage.permission_denials && resultMessage.permission_denials.length > 0) {
|
||||
console.log(chalk.yellow(` 🚫 ${resultMessage.permission_denials.length} permission denials`));
|
||||
}
|
||||
} else {
|
||||
// Full completion output for agents without clean output
|
||||
console.log(chalk.magenta(`\n 🏁 COMPLETED:`));
|
||||
const cost = resultMessage.total_cost_usd || 0;
|
||||
console.log(chalk.gray(` ⏱️ Duration: ${((resultMessage.duration_ms || 0)/1000).toFixed(1)}s, Cost: $${cost.toFixed(4)}`));
|
||||
|
||||
if (resultMessage.subtype === "error_max_turns") {
|
||||
console.log(chalk.red(` ⚠️ Stopped: Hit maximum turns limit`));
|
||||
} else if (resultMessage.subtype === "error_during_execution") {
|
||||
console.log(chalk.red(` ❌ Stopped: Execution error`));
|
||||
}
|
||||
|
||||
if (resultMessage.permission_denials && resultMessage.permission_denials.length > 0) {
|
||||
console.log(chalk.yellow(` 🚫 ${resultMessage.permission_denials.length} permission denials`));
|
||||
}
|
||||
|
||||
// Show result content (if it's reasonable length)
|
||||
if (result && typeof result === 'string') {
|
||||
if (result.length > 1000) {
|
||||
console.log(chalk.magenta(` 📄 ${result.slice(0, 1000)}... [${result.length} total chars]`));
|
||||
} else {
|
||||
console.log(chalk.magenta(` 📄 ${result}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track cost for all agents
|
||||
const cost = resultMessage.total_cost_usd || 0;
|
||||
const agentKey = description.toLowerCase().replace(/\s+/g, '-');
|
||||
costResults.agents[agentKey] = cost;
|
||||
costResults.total += cost;
|
||||
|
||||
// Store cost for return value and partial tracking
|
||||
totalCost = cost;
|
||||
partialCost = cost;
|
||||
break;
|
||||
} else {
|
||||
// Log any other message types we might not be handling
|
||||
console.log(chalk.gray(` 💬 ${message.type}: ${JSON.stringify(message, null, 2)}`));
|
||||
}
|
||||
|
||||
// Track cost for all agents
|
||||
const cost = message.total_cost_usd || 0;
|
||||
const agentKey = description.toLowerCase().replace(/\s+/g, '-');
|
||||
costResults.agents[agentKey] = cost;
|
||||
costResults.total += cost;
|
||||
|
||||
// Store cost for return value and partial tracking
|
||||
totalCost = cost;
|
||||
partialCost = cost;
|
||||
break;
|
||||
} else {
|
||||
// Log any other message types we might not be handling
|
||||
console.log(chalk.gray(` 💬 ${message.type}: ${JSON.stringify(message, null, 2)}`));
|
||||
}
|
||||
}
|
||||
} catch (queryError) {
|
||||
throw queryError; // Re-throw to outer catch
|
||||
@@ -415,44 +461,31 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
timingResults.agents[agentKey] = duration;
|
||||
|
||||
// API error detection is logged but not immediately failed
|
||||
// Let the retry logic handle validation first
|
||||
if (apiErrorDetected) {
|
||||
console.log(chalk.yellow(` ⚠️ API Error detected in ${description} - will validate deliverables before failing`));
|
||||
}
|
||||
|
||||
// Finish status line for parallel execution
|
||||
if (statusManager) {
|
||||
statusManager.clearAgentStatus(description);
|
||||
statusManager.finishStatusLine();
|
||||
}
|
||||
|
||||
// NOTE: Log writing now handled by AuditSession (crash-safe, append-only)
|
||||
// Legacy log writing removed - audit system handles this automatically
|
||||
|
||||
// Show completion messages based on agent type
|
||||
if (progressIndicator) {
|
||||
// Single agents with progress indicator
|
||||
const agentType = description.includes('Pre-recon') ? 'Pre-recon analysis' :
|
||||
description.includes('Recon') ? 'Reconnaissance' :
|
||||
description.includes('Report') ? 'Report generation' : 'Analysis';
|
||||
progressIndicator.finish(`${agentType} complete! (${turnCount} turns, ${formatDuration(duration)})`);
|
||||
} else if (isParallelExecution) {
|
||||
// Compact completion for parallel agents
|
||||
const prefix = getAgentPrefix(description);
|
||||
console.log(chalk.green(`${prefix} ✅ Complete (${turnCount} turns, ${formatDuration(duration)})`));
|
||||
} else if (!useCleanOutput) {
|
||||
// Verbose completion for remaining agents
|
||||
console.log(chalk.green(` ✅ Claude Code completed: ${description} (${turnCount} turns) in ${formatDuration(duration)}`));
|
||||
}
|
||||
|
||||
// Return result with log file path for all agents
|
||||
const returnData = {
|
||||
const returnData: ClaudePromptResult = {
|
||||
result,
|
||||
success: true,
|
||||
duration,
|
||||
turns: turnCount,
|
||||
cost: totalCost,
|
||||
partialCost, // Include partial cost for crash recovery
|
||||
partialCost,
|
||||
apiErrorDetected
|
||||
};
|
||||
if (logFilePath) {
|
||||
@@ -465,18 +498,14 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
const agentKey = description.toLowerCase().replace(/\s+/g, '-');
|
||||
timingResults.agents[agentKey] = duration;
|
||||
|
||||
// Clear status for parallel execution before showing error
|
||||
if (statusManager) {
|
||||
statusManager.clearAgentStatus(description);
|
||||
statusManager.finishStatusLine();
|
||||
}
|
||||
const err = error as Error & { code?: string; status?: number; duration?: number; cost?: number };
|
||||
|
||||
// Log error to audit system
|
||||
if (auditSession) {
|
||||
await auditSession.logEvent('error', {
|
||||
message: error.message,
|
||||
errorType: error.constructor.name,
|
||||
stack: error.stack,
|
||||
message: err.message,
|
||||
errorType: err.constructor.name,
|
||||
stack: err.stack,
|
||||
duration,
|
||||
turns: turnCount,
|
||||
timestamp: new Date().toISOString()
|
||||
@@ -485,32 +514,29 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
|
||||
// Show error messages based on agent type
|
||||
if (progressIndicator) {
|
||||
// Single agents with progress indicator
|
||||
progressIndicator.stop();
|
||||
const agentType = description.includes('Pre-recon') ? 'Pre-recon analysis' :
|
||||
description.includes('Recon') ? 'Reconnaissance' :
|
||||
description.includes('Report') ? 'Report generation' : 'Analysis';
|
||||
console.log(chalk.red(`❌ ${agentType} failed (${formatDuration(duration)})`));
|
||||
} else if (isParallelExecution) {
|
||||
// Compact error for parallel agents
|
||||
const prefix = getAgentPrefix(description);
|
||||
console.log(chalk.red(`${prefix} ❌ Failed (${formatDuration(duration)})`));
|
||||
} else if (!useCleanOutput) {
|
||||
// Verbose error for remaining agents
|
||||
console.log(chalk.red(` ❌ Claude Code failed: ${description} (${formatDuration(duration)})`));
|
||||
}
|
||||
console.log(chalk.red(` Error Type: ${error.constructor.name}`));
|
||||
console.log(chalk.red(` Message: ${error.message}`));
|
||||
console.log(chalk.red(` Error Type: ${err.constructor.name}`));
|
||||
console.log(chalk.red(` Message: ${err.message}`));
|
||||
console.log(chalk.gray(` Agent: ${description}`));
|
||||
console.log(chalk.gray(` Working Directory: ${sourceDir}`));
|
||||
console.log(chalk.gray(` Retryable: ${isRetryableError(error) ? 'Yes' : 'No'}`));
|
||||
console.log(chalk.gray(` Retryable: ${isRetryableError(err) ? 'Yes' : 'No'}`));
|
||||
|
||||
// Log additional context if available
|
||||
if (error.code) {
|
||||
console.log(chalk.gray(` Error Code: ${error.code}`));
|
||||
if (err.code) {
|
||||
console.log(chalk.gray(` Error Code: ${err.code}`));
|
||||
}
|
||||
if (error.status) {
|
||||
console.log(chalk.gray(` HTTP Status: ${error.status}`));
|
||||
if (err.status) {
|
||||
console.log(chalk.gray(` HTTP Status: ${err.status}`));
|
||||
}
|
||||
|
||||
// Save detailed error to log file for debugging
|
||||
@@ -519,16 +545,16 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
timestamp: new Date().toISOString(),
|
||||
agent: description,
|
||||
error: {
|
||||
name: error.constructor.name,
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.status,
|
||||
stack: error.stack
|
||||
name: err.constructor.name,
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
status: err.status,
|
||||
stack: err.stack
|
||||
},
|
||||
context: {
|
||||
sourceDir,
|
||||
prompt: fullPrompt.slice(0, 200) + '...',
|
||||
retryable: isRetryableError(error)
|
||||
retryable: isRetryableError(err)
|
||||
},
|
||||
duration
|
||||
};
|
||||
@@ -536,39 +562,41 @@ async function runClaudePrompt(prompt, sourceDir, allowedTools = 'Read', context
|
||||
const logPath = path.join(sourceDir, 'error.log');
|
||||
await fs.appendFile(logPath, JSON.stringify(errorLog) + '\n');
|
||||
} catch (logError) {
|
||||
// Ignore logging errors to avoid cascading failures
|
||||
console.log(chalk.gray(` (Failed to write error log: ${logError.message})`));
|
||||
const logErrMsg = logError instanceof Error ? logError.message : String(logError);
|
||||
console.log(chalk.gray(` (Failed to write error log: ${logErrMsg})`));
|
||||
}
|
||||
|
||||
return {
|
||||
error: error.message,
|
||||
errorType: error.constructor.name,
|
||||
error: err.message,
|
||||
errorType: err.constructor.name,
|
||||
prompt: fullPrompt.slice(0, 100) + '...',
|
||||
success: false,
|
||||
duration,
|
||||
cost: partialCost, // Include partial cost on error
|
||||
retryable: isRetryableError(error)
|
||||
cost: partialCost,
|
||||
retryable: isRetryableError(err)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// PREFERRED: Production-ready Claude agent execution with full orchestration
|
||||
// This is the standard function for all agent execution. Provides:
|
||||
// - Intelligent retry logic with exponential backoff
|
||||
// - Output validation to ensure deliverables are created
|
||||
// - Prompt snapshotting for debugging and reproducibility
|
||||
// - Git checkpoint/rollback safety for workspace protection
|
||||
// - Comprehensive error handling and logging
|
||||
// - Crash-safe audit logging via AuditSession
|
||||
export async function runClaudePromptWithRetry(prompt, sourceDir, allowedTools = 'Read', context = '', description = 'Claude analysis', agentName = null, colorFn = chalk.cyan, sessionMetadata = null) {
|
||||
export async function runClaudePromptWithRetry(
|
||||
prompt: string,
|
||||
sourceDir: string,
|
||||
allowedTools: string = 'Read',
|
||||
context: string = '',
|
||||
description: string = 'Claude analysis',
|
||||
agentName: string | null = null,
|
||||
colorFn: ChalkInstance = chalk.cyan,
|
||||
sessionMetadata: SessionMetadata | null = null
|
||||
): Promise<ClaudePromptResult> {
|
||||
const maxRetries = 3;
|
||||
let lastError;
|
||||
let retryContext = context; // Preserve context between retries
|
||||
let lastError: Error | undefined;
|
||||
let retryContext = context;
|
||||
|
||||
console.log(chalk.cyan(`🚀 Starting ${description} with ${maxRetries} max attempts`));
|
||||
|
||||
// Initialize audit session (crash-safe logging)
|
||||
let auditSession = null;
|
||||
let auditSession: AuditSession | null = null;
|
||||
if (sessionMetadata && agentName) {
|
||||
auditSession = new AuditSession(sessionMetadata);
|
||||
await auditSession.initialize();
|
||||
@@ -579,7 +607,7 @@ export async function runClaudePromptWithRetry(prompt, sourceDir, allowedTools =
|
||||
await createGitCheckpoint(sourceDir, description, attempt);
|
||||
|
||||
// Start agent tracking in audit system (saves prompt snapshot automatically)
|
||||
if (auditSession) {
|
||||
if (auditSession && agentName) {
|
||||
const fullPrompt = retryContext ? `${retryContext}\n\n${prompt}` : prompt;
|
||||
await auditSession.startAgent(agentName, fullPrompt, attempt);
|
||||
}
|
||||
@@ -598,14 +626,24 @@ export async function runClaudePromptWithRetry(prompt, sourceDir, allowedTools =
|
||||
}
|
||||
|
||||
// Record successful attempt in audit system
|
||||
if (auditSession) {
|
||||
await auditSession.endAgent(agentName, {
|
||||
if (auditSession && agentName) {
|
||||
const commitHash = await getGitCommitHash(sourceDir);
|
||||
const endResult: {
|
||||
attemptNumber: number;
|
||||
duration_ms: number;
|
||||
cost_usd: number;
|
||||
success: true;
|
||||
checkpoint?: string;
|
||||
} = {
|
||||
attemptNumber: attempt,
|
||||
duration_ms: result.duration,
|
||||
cost_usd: result.cost || 0,
|
||||
success: true,
|
||||
checkpoint: await getGitCommitHash(sourceDir)
|
||||
});
|
||||
};
|
||||
if (commitHash) {
|
||||
endResult.checkpoint = commitHash;
|
||||
}
|
||||
await auditSession.endAgent(agentName, endResult);
|
||||
}
|
||||
|
||||
// Commit successful changes (will include the snapshot)
|
||||
@@ -617,7 +655,7 @@ export async function runClaudePromptWithRetry(prompt, sourceDir, allowedTools =
|
||||
console.log(chalk.yellow(`⚠️ ${description} completed but output validation failed`));
|
||||
|
||||
// Record failed validation attempt in audit system
|
||||
if (auditSession) {
|
||||
if (auditSession && agentName) {
|
||||
await auditSession.endAgent(agentName, {
|
||||
attemptNumber: attempt,
|
||||
duration_ms: result.duration,
|
||||
@@ -653,47 +691,48 @@ export async function runClaudePromptWithRetry(prompt, sourceDir, allowedTools =
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const err = error as Error & { duration?: number; cost?: number; partialResults?: unknown };
|
||||
lastError = err;
|
||||
|
||||
// Record failed attempt in audit system
|
||||
if (auditSession) {
|
||||
if (auditSession && agentName) {
|
||||
await auditSession.endAgent(agentName, {
|
||||
attemptNumber: attempt,
|
||||
duration_ms: error.duration || 0,
|
||||
cost_usd: error.cost || 0,
|
||||
duration_ms: err.duration || 0,
|
||||
cost_usd: err.cost || 0,
|
||||
success: false,
|
||||
error: error.message,
|
||||
error: err.message,
|
||||
isFinalAttempt: attempt === maxRetries
|
||||
});
|
||||
}
|
||||
|
||||
// Check if error is retryable
|
||||
if (!isRetryableError(error)) {
|
||||
console.log(chalk.red(`❌ ${description} failed with non-retryable error: ${error.message}`));
|
||||
if (!isRetryableError(err)) {
|
||||
console.log(chalk.red(`❌ ${description} failed with non-retryable error: ${err.message}`));
|
||||
await rollbackGitWorkspace(sourceDir, 'non-retryable error cleanup');
|
||||
throw error;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
// Rollback for clean retry
|
||||
await rollbackGitWorkspace(sourceDir, 'retryable error cleanup');
|
||||
|
||||
const delay = getRetryDelay(error, attempt);
|
||||
const delay = getRetryDelay(err, attempt);
|
||||
const delaySeconds = (delay / 1000).toFixed(1);
|
||||
console.log(chalk.yellow(`⚠️ ${description} failed (attempt ${attempt}/${maxRetries})`));
|
||||
console.log(chalk.gray(` Error: ${error.message}`));
|
||||
console.log(chalk.gray(` Error: ${err.message}`));
|
||||
console.log(chalk.gray(` Workspace rolled back, retrying in ${delaySeconds}s...`));
|
||||
|
||||
// Preserve any partial results for next retry
|
||||
if (error.partialResults) {
|
||||
retryContext = `${context}\n\nPrevious partial results: ${JSON.stringify(error.partialResults)}`;
|
||||
if (err.partialResults) {
|
||||
retryContext = `${context}\n\nPrevious partial results: ${JSON.stringify(err.partialResults)}`;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
} else {
|
||||
await rollbackGitWorkspace(sourceDir, 'final failure cleanup');
|
||||
console.log(chalk.red(`❌ ${description} failed after ${maxRetries} attempts`));
|
||||
console.log(chalk.red(` Final error: ${error.message}`));
|
||||
console.log(chalk.red(` Final error: ${err.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -702,11 +741,11 @@ export async function runClaudePromptWithRetry(prompt, sourceDir, allowedTools =
|
||||
}
|
||||
|
||||
// Helper function to get git commit hash
|
||||
async function getGitCommitHash(sourceDir) {
|
||||
async function getGitCommitHash(sourceDir: string): Promise<string | null> {
|
||||
try {
|
||||
const result = await $`cd ${sourceDir} && git rev-parse HEAD`;
|
||||
return result.stdout.trim();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,23 +13,33 @@
|
||||
|
||||
import { AgentLogger } from './logger.js';
|
||||
import { MetricsTracker } from './metrics-tracker.js';
|
||||
import { initializeAuditStructure, formatTimestamp } from './utils.js';
|
||||
import { initializeAuditStructure, formatTimestamp, type SessionMetadata } from './utils.js';
|
||||
import { SessionMutex } from '../utils/concurrency.js';
|
||||
|
||||
// Global mutex instance
|
||||
const sessionMutex = new SessionMutex();
|
||||
|
||||
interface AgentEndResult {
|
||||
attemptNumber: number;
|
||||
duration_ms: number;
|
||||
cost_usd: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
checkpoint?: string;
|
||||
isFinalAttempt?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuditSession - Main audit system facade
|
||||
*/
|
||||
export class AuditSession {
|
||||
/**
|
||||
* @param {Object} sessionMetadata - Session metadata from Shannon store
|
||||
* @param {string} sessionMetadata.id - Session UUID
|
||||
* @param {string} sessionMetadata.webUrl - Target web URL
|
||||
* @param {string} [sessionMetadata.repoPath] - Target repository path
|
||||
*/
|
||||
constructor(sessionMetadata) {
|
||||
private sessionMetadata: SessionMetadata;
|
||||
private sessionId: string;
|
||||
private metricsTracker: MetricsTracker;
|
||||
private currentLogger: AgentLogger | null = null;
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor(sessionMetadata: SessionMetadata) {
|
||||
this.sessionMetadata = sessionMetadata;
|
||||
this.sessionId = sessionMetadata.id;
|
||||
|
||||
@@ -43,20 +53,13 @@ export class AuditSession {
|
||||
|
||||
// Components
|
||||
this.metricsTracker = new MetricsTracker(sessionMetadata);
|
||||
|
||||
// Active logger (one at a time per agent attempt)
|
||||
this.currentLogger = null;
|
||||
|
||||
// Initialization flag
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize audit session (creates directories, session.json)
|
||||
* Idempotent and race-safe
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async initialize() {
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return; // Already initialized
|
||||
}
|
||||
@@ -72,10 +75,8 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* Ensure initialized (helper for lazy initialization)
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ensureInitialized() {
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
@@ -83,12 +84,12 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* Start agent execution
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {string} promptContent - Full prompt content
|
||||
* @param {number} [attemptNumber=1] - Attempt number
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async startAgent(agentName, promptContent, attemptNumber = 1) {
|
||||
async startAgent(
|
||||
agentName: string,
|
||||
promptContent: string,
|
||||
attemptNumber: number = 1
|
||||
): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
// Save prompt snapshot (only on first attempt)
|
||||
@@ -107,17 +108,14 @@ export class AuditSession {
|
||||
await this.currentLogger.logEvent('agent_start', {
|
||||
agentName,
|
||||
attemptNumber,
|
||||
timestamp: formatTimestamp()
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log event during agent execution
|
||||
* @param {string} eventType - Event type (tool_start, tool_end, llm_response, etc.)
|
||||
* @param {Object} eventData - Event data
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logEvent(eventType, eventData) {
|
||||
async logEvent(eventType: string, eventData: unknown): Promise<void> {
|
||||
if (!this.currentLogger) {
|
||||
throw new Error('No active logger. Call startAgent() first.');
|
||||
}
|
||||
@@ -127,18 +125,8 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* End agent execution (mutex-protected)
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {Object} result - Execution result
|
||||
* @param {number} result.attemptNumber - Attempt number
|
||||
* @param {number} result.duration_ms - Duration in milliseconds
|
||||
* @param {number} result.cost_usd - Cost in USD
|
||||
* @param {boolean} result.success - Whether attempt succeeded
|
||||
* @param {string} [result.error] - Error message (if failed)
|
||||
* @param {string} [result.checkpoint] - Git checkpoint hash (if succeeded)
|
||||
* @param {boolean} [result.isFinalAttempt=false] - Whether this is the final attempt
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async endAgent(agentName, result) {
|
||||
async endAgent(agentName: string, result: AgentEndResult): Promise<void> {
|
||||
// Log end event
|
||||
if (this.currentLogger) {
|
||||
await this.currentLogger.logEvent('agent_end', {
|
||||
@@ -146,7 +134,7 @@ export class AuditSession {
|
||||
success: result.success,
|
||||
duration_ms: result.duration_ms,
|
||||
cost_usd: result.cost_usd,
|
||||
timestamp: formatTimestamp()
|
||||
timestamp: formatTimestamp(),
|
||||
});
|
||||
|
||||
// Close logger
|
||||
@@ -169,10 +157,8 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* Mark multiple agents as rolled back
|
||||
* @param {string[]} agentNames - Array of agent names
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async markMultipleRolledBack(agentNames) {
|
||||
async markMultipleRolledBack(agentNames: string[]): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
@@ -186,10 +172,8 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* Update session status
|
||||
* @param {string} status - New status (in-progress, completed, failed)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateSessionStatus(status) {
|
||||
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed'): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const unlock = await sessionMutex.lock(this.sessionId);
|
||||
@@ -203,9 +187,8 @@ export class AuditSession {
|
||||
|
||||
/**
|
||||
* Get current metrics (read-only)
|
||||
* @returns {Promise<Object>} Current metrics
|
||||
*/
|
||||
async getMetrics() {
|
||||
async getMetrics(): Promise<unknown> {
|
||||
await this.ensureInitialized();
|
||||
return this.metricsTracker.getMetrics();
|
||||
}
|
||||
@@ -12,18 +12,33 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import { generateLogPath, generatePromptPath, atomicWrite, formatTimestamp } from './utils.js';
|
||||
import {
|
||||
generateLogPath,
|
||||
generatePromptPath,
|
||||
atomicWrite,
|
||||
formatTimestamp,
|
||||
type SessionMetadata,
|
||||
} from './utils.js';
|
||||
|
||||
interface LogEvent {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentLogger - Manages append-only logging for a single agent execution
|
||||
*/
|
||||
export class AgentLogger {
|
||||
/**
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @param {string} agentName - Name of the agent
|
||||
* @param {number} attemptNumber - Attempt number (1, 2, 3, ...)
|
||||
*/
|
||||
constructor(sessionMetadata, agentName, attemptNumber) {
|
||||
private sessionMetadata: SessionMetadata;
|
||||
private agentName: string;
|
||||
private attemptNumber: number;
|
||||
private timestamp: number;
|
||||
private logPath: string;
|
||||
private stream: fs.WriteStream | null = null;
|
||||
private isOpen: boolean = false;
|
||||
|
||||
constructor(sessionMetadata: SessionMetadata, agentName: string, attemptNumber: number) {
|
||||
this.sessionMetadata = sessionMetadata;
|
||||
this.agentName = agentName;
|
||||
this.attemptNumber = attemptNumber;
|
||||
@@ -31,17 +46,12 @@ export class AgentLogger {
|
||||
|
||||
// Generate log file path
|
||||
this.logPath = generateLogPath(sessionMetadata, agentName, this.timestamp, attemptNumber);
|
||||
|
||||
// Create write stream (append mode)
|
||||
this.stream = null;
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the log stream (creates file and opens stream)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async initialize() {
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isOpen) {
|
||||
return; // Already initialized
|
||||
}
|
||||
@@ -50,7 +60,7 @@ export class AgentLogger {
|
||||
this.stream = fs.createWriteStream(this.logPath, {
|
||||
flags: 'a', // Append mode
|
||||
encoding: 'utf8',
|
||||
autoClose: true
|
||||
autoClose: true,
|
||||
});
|
||||
|
||||
this.isOpen = true;
|
||||
@@ -61,10 +71,8 @@ export class AgentLogger {
|
||||
|
||||
/**
|
||||
* Write header to log file
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async writeHeader() {
|
||||
private async writeHeader(): Promise<void> {
|
||||
const header = [
|
||||
`========================================`,
|
||||
`Agent: ${this.agentName}`,
|
||||
@@ -72,7 +80,7 @@ export class AgentLogger {
|
||||
`Started: ${formatTimestamp(this.timestamp)}`,
|
||||
`Session: ${this.sessionMetadata.id}`,
|
||||
`Web URL: ${this.sessionMetadata.webUrl}`,
|
||||
`========================================\n`
|
||||
`========================================\n`,
|
||||
].join('\n');
|
||||
|
||||
return this.writeRaw(header);
|
||||
@@ -80,11 +88,8 @@ export class AgentLogger {
|
||||
|
||||
/**
|
||||
* Write raw text to log file with immediate flush
|
||||
* @private
|
||||
* @param {string} text - Text to write
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
writeRaw(text) {
|
||||
private writeRaw(text: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isOpen || !this.stream) {
|
||||
reject(new Error('Logger not initialized'));
|
||||
@@ -100,8 +105,8 @@ export class AgentLogger {
|
||||
|
||||
if (needsDrain) {
|
||||
// Buffer is full, wait for drain
|
||||
const drainHandler = () => {
|
||||
this.stream.removeListener('drain', drainHandler);
|
||||
const drainHandler = (): void => {
|
||||
this.stream!.removeListener('drain', drainHandler);
|
||||
resolve();
|
||||
};
|
||||
this.stream.once('drain', drainHandler);
|
||||
@@ -115,15 +120,12 @@ export class AgentLogger {
|
||||
/**
|
||||
* Log an event (tool_start, tool_end, llm_response, etc.)
|
||||
* Events are logged as JSON for parseability
|
||||
* @param {string} eventType - Type of event
|
||||
* @param {Object} eventData - Event data
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logEvent(eventType, eventData) {
|
||||
const event = {
|
||||
async logEvent(eventType: string, eventData: unknown): Promise<void> {
|
||||
const event: LogEvent = {
|
||||
type: eventType,
|
||||
timestamp: formatTimestamp(),
|
||||
data: eventData
|
||||
data: eventData,
|
||||
};
|
||||
|
||||
const eventLine = `${JSON.stringify(event)}\n`;
|
||||
@@ -132,15 +134,14 @@ export class AgentLogger {
|
||||
|
||||
/**
|
||||
* Close the log stream
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async close() {
|
||||
async close(): Promise<void> {
|
||||
if (!this.isOpen || !this.stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.stream.end(() => {
|
||||
this.stream!.end(() => {
|
||||
this.isOpen = false;
|
||||
resolve();
|
||||
});
|
||||
@@ -150,12 +151,12 @@ export class AgentLogger {
|
||||
/**
|
||||
* Save prompt snapshot to prompts directory
|
||||
* Static method - doesn't require logger instance
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {string} promptContent - Full prompt content
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async savePrompt(sessionMetadata, agentName, promptContent) {
|
||||
static async savePrompt(
|
||||
sessionMetadata: SessionMetadata,
|
||||
agentName: string,
|
||||
promptContent: string
|
||||
): Promise<void> {
|
||||
const promptPath = generatePromptPath(sessionMetadata, agentName);
|
||||
|
||||
// Create header with metadata
|
||||
@@ -167,7 +168,7 @@ export class AgentLogger {
|
||||
`**Saved:** ${formatTimestamp()}`,
|
||||
``,
|
||||
`---`,
|
||||
``
|
||||
``,
|
||||
].join('\n');
|
||||
|
||||
const fullContent = header + promptContent;
|
||||
@@ -17,38 +17,92 @@ import {
|
||||
readJson,
|
||||
fileExists,
|
||||
formatTimestamp,
|
||||
calculatePercentage
|
||||
calculatePercentage,
|
||||
type SessionMetadata,
|
||||
} from './utils.js';
|
||||
import type { AgentName, PhaseName } from '../types/index.js';
|
||||
|
||||
interface AttemptData {
|
||||
attempt_number: number;
|
||||
duration_ms: number;
|
||||
cost_usd: number;
|
||||
success: boolean;
|
||||
timestamp: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AgentMetrics {
|
||||
status: 'in-progress' | 'success' | 'failed' | 'rolled-back';
|
||||
attempts: AttemptData[];
|
||||
final_duration_ms: number;
|
||||
total_cost_usd: number;
|
||||
checkpoint?: string;
|
||||
rolled_back_at?: string;
|
||||
}
|
||||
|
||||
interface PhaseMetrics {
|
||||
duration_ms: number;
|
||||
duration_percentage: number;
|
||||
cost_usd: number;
|
||||
agent_count: number;
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
session: {
|
||||
id: string;
|
||||
webUrl: string;
|
||||
repoPath?: string;
|
||||
status: 'in-progress' | 'completed' | 'failed';
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
};
|
||||
metrics: {
|
||||
total_duration_ms: number;
|
||||
total_cost_usd: number;
|
||||
phases: Record<string, PhaseMetrics>;
|
||||
agents: Record<string, AgentMetrics>;
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentEndResult {
|
||||
attemptNumber: number;
|
||||
duration_ms: number;
|
||||
cost_usd: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
checkpoint?: string;
|
||||
isFinalAttempt?: boolean;
|
||||
}
|
||||
|
||||
interface ActiveTimer {
|
||||
startTime: number;
|
||||
attemptNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MetricsTracker - Manages metrics for a session
|
||||
*/
|
||||
export class MetricsTracker {
|
||||
/**
|
||||
* @param {Object} sessionMetadata - Session metadata from Shannon store
|
||||
*/
|
||||
constructor(sessionMetadata) {
|
||||
private sessionMetadata: SessionMetadata;
|
||||
private sessionJsonPath: string;
|
||||
private data: SessionData | null = null;
|
||||
private activeTimers: Map<string, ActiveTimer> = new Map();
|
||||
|
||||
constructor(sessionMetadata: SessionMetadata) {
|
||||
this.sessionMetadata = sessionMetadata;
|
||||
this.sessionJsonPath = generateSessionJsonPath(sessionMetadata);
|
||||
|
||||
// In-memory state (loaded from/synced to session.json)
|
||||
this.data = null;
|
||||
|
||||
// Active timers (agent name -> start time)
|
||||
this.activeTimers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize session.json (idempotent)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async initialize() {
|
||||
async initialize(): Promise<void> {
|
||||
// Check if session.json already exists
|
||||
const exists = await fileExists(this.sessionJsonPath);
|
||||
|
||||
if (exists) {
|
||||
// Load existing data
|
||||
this.data = await readJson(this.sessionJsonPath);
|
||||
this.data = await readJson<SessionData>(this.sessionJsonPath);
|
||||
} else {
|
||||
// Create new session.json
|
||||
this.data = this.createInitialData();
|
||||
@@ -58,72 +112,66 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Create initial session.json structure
|
||||
* @private
|
||||
* @returns {Object} Initial session data
|
||||
*/
|
||||
createInitialData() {
|
||||
return {
|
||||
private createInitialData(): SessionData {
|
||||
const sessionData: SessionData = {
|
||||
session: {
|
||||
id: this.sessionMetadata.id,
|
||||
webUrl: this.sessionMetadata.webUrl,
|
||||
repoPath: this.sessionMetadata.repoPath,
|
||||
status: 'in-progress',
|
||||
createdAt: this.sessionMetadata.createdAt || formatTimestamp()
|
||||
createdAt: (this.sessionMetadata as { createdAt?: string }).createdAt || formatTimestamp(),
|
||||
},
|
||||
metrics: {
|
||||
total_duration_ms: 0,
|
||||
total_cost_usd: 0,
|
||||
phases: {}, // Phase-level aggregations: { duration_ms, duration_percentage, cost_usd, agent_count }
|
||||
agents: {} // Agent-level metrics: { status, attempts[], final_duration_ms, total_cost_usd, checkpoint }
|
||||
}
|
||||
phases: {}, // Phase-level aggregations
|
||||
agents: {}, // Agent-level metrics
|
||||
},
|
||||
};
|
||||
// Only add repoPath if it exists
|
||||
if (this.sessionMetadata.repoPath) {
|
||||
sessionData.session.repoPath = this.sessionMetadata.repoPath;
|
||||
}
|
||||
return sessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking an agent execution
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {number} attemptNumber - Attempt number
|
||||
* @returns {void}
|
||||
*/
|
||||
startAgent(agentName, attemptNumber) {
|
||||
startAgent(agentName: string, attemptNumber: number): void {
|
||||
this.activeTimers.set(agentName, {
|
||||
startTime: Date.now(),
|
||||
attemptNumber
|
||||
attemptNumber,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End agent execution and update metrics
|
||||
* @param {string} agentName - Agent name
|
||||
* @param {Object} result - Agent execution result
|
||||
* @param {number} result.attemptNumber - Attempt number
|
||||
* @param {number} result.duration_ms - Duration in milliseconds
|
||||
* @param {number} result.cost_usd - Cost in USD
|
||||
* @param {boolean} result.success - Whether attempt succeeded
|
||||
* @param {string} [result.error] - Error message (if failed)
|
||||
* @param {string} [result.checkpoint] - Git checkpoint hash (if succeeded)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async endAgent(agentName, result) {
|
||||
async endAgent(agentName: string, result: AgentEndResult): Promise<void> {
|
||||
if (!this.data) {
|
||||
throw new Error('MetricsTracker not initialized');
|
||||
}
|
||||
|
||||
// Initialize agent metrics if not exists
|
||||
if (!this.data.metrics.agents[agentName]) {
|
||||
this.data.metrics.agents[agentName] = {
|
||||
status: 'in-progress',
|
||||
attempts: [],
|
||||
final_duration_ms: 0,
|
||||
total_cost_usd: 0 // Total cost across all attempts (including retries)
|
||||
total_cost_usd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const agent = this.data.metrics.agents[agentName];
|
||||
const agent = this.data.metrics.agents[agentName]!;
|
||||
|
||||
// Add attempt to array
|
||||
const attempt = {
|
||||
const attempt: AttemptData = {
|
||||
attempt_number: result.attemptNumber,
|
||||
duration_ms: result.duration_ms,
|
||||
cost_usd: result.cost_usd,
|
||||
success: result.success,
|
||||
timestamp: formatTimestamp()
|
||||
timestamp: formatTimestamp(),
|
||||
};
|
||||
|
||||
if (result.error) {
|
||||
@@ -162,15 +210,13 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Mark agent as rolled back
|
||||
* @param {string} agentName - Agent name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async markRolledBack(agentName) {
|
||||
if (!this.data.metrics.agents[agentName]) {
|
||||
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];
|
||||
const agent = this.data.metrics.agents[agentName]!;
|
||||
agent.status = 'rolled-back';
|
||||
agent.rolled_back_at = formatTimestamp();
|
||||
|
||||
@@ -182,13 +228,13 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Mark multiple agents as rolled back
|
||||
* @param {string[]} agentNames - Array of agent names
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async markMultipleRolledBack(agentNames) {
|
||||
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];
|
||||
const agent = this.data.metrics.agents[agentName]!;
|
||||
agent.status = 'rolled-back';
|
||||
agent.rolled_back_at = formatTimestamp();
|
||||
}
|
||||
@@ -200,10 +246,10 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Update session status
|
||||
* @param {string} status - New status (in-progress, completed, failed)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateSessionStatus(status) {
|
||||
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed'): Promise<void> {
|
||||
if (!this.data) return;
|
||||
|
||||
this.data.session.status = status;
|
||||
|
||||
if (status === 'completed' || status === 'failed') {
|
||||
@@ -215,25 +261,24 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Recalculate aggregations (total duration, total cost, phases)
|
||||
* @private
|
||||
*/
|
||||
recalculateAggregations() {
|
||||
private recalculateAggregations(): void {
|
||||
if (!this.data) return;
|
||||
|
||||
const agents = this.data.metrics.agents;
|
||||
|
||||
// Only count successful agents (not rolled-back or failed)
|
||||
const successfulAgents = Object.entries(agents)
|
||||
.filter(([_, data]) => data.status === 'success');
|
||||
const successfulAgents = Object.entries(agents).filter(
|
||||
([, data]) => data.status === 'success'
|
||||
);
|
||||
|
||||
// Calculate total duration and cost
|
||||
const totalDuration = successfulAgents.reduce(
|
||||
(sum, [_, data]) => sum + data.final_duration_ms,
|
||||
(sum, [, data]) => sum + data.final_duration_ms,
|
||||
0
|
||||
);
|
||||
|
||||
const totalCost = successfulAgents.reduce(
|
||||
(sum, [_, data]) => sum + data.total_cost_usd,
|
||||
0
|
||||
);
|
||||
const totalCost = successfulAgents.reduce((sum, [, data]) => sum + data.total_cost_usd, 0);
|
||||
|
||||
this.data.metrics.total_duration_ms = totalDuration;
|
||||
this.data.metrics.total_cost_usd = totalCost;
|
||||
@@ -244,23 +289,22 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Calculate phase-level metrics
|
||||
* @private
|
||||
* @param {Array} successfulAgents - Array of [agentName, agentData] tuples
|
||||
* @returns {Object} Phase metrics
|
||||
*/
|
||||
calculatePhaseMetrics(successfulAgents) {
|
||||
const phases = {
|
||||
private calculatePhaseMetrics(
|
||||
successfulAgents: Array<[string, AgentMetrics]>
|
||||
): Record<string, PhaseMetrics> {
|
||||
const phases: Record<string, AgentMetrics[]> = {
|
||||
'pre-recon': [],
|
||||
'recon': [],
|
||||
recon: [],
|
||||
'vulnerability-analysis': [],
|
||||
'exploitation': [],
|
||||
'reporting': []
|
||||
exploitation: [],
|
||||
reporting: [],
|
||||
};
|
||||
|
||||
// Map agents to phases
|
||||
const agentPhaseMap = {
|
||||
const agentPhaseMap: Record<string, string> = {
|
||||
'pre-recon': 'pre-recon',
|
||||
'recon': 'recon',
|
||||
recon: 'recon',
|
||||
'injection-vuln': 'vulnerability-analysis',
|
||||
'xss-vuln': 'vulnerability-analysis',
|
||||
'auth-vuln': 'vulnerability-analysis',
|
||||
@@ -271,39 +315,33 @@ export class MetricsTracker {
|
||||
'auth-exploit': 'exploitation',
|
||||
'authz-exploit': 'exploitation',
|
||||
'ssrf-exploit': 'exploitation',
|
||||
'report': 'reporting'
|
||||
report: 'reporting',
|
||||
};
|
||||
|
||||
// Group agents by phase
|
||||
for (const [agentName, agentData] of successfulAgents) {
|
||||
const phase = agentPhaseMap[agentName];
|
||||
if (phase) {
|
||||
phases[phase].push(agentData);
|
||||
if (phase && phases[phase]) {
|
||||
phases[phase]!.push(agentData);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate metrics per phase
|
||||
const phaseMetrics = {};
|
||||
const totalDuration = this.data.metrics.total_duration_ms;
|
||||
const phaseMetrics: Record<string, PhaseMetrics> = {};
|
||||
const totalDuration = this.data!.metrics.total_duration_ms;
|
||||
|
||||
for (const [phaseName, agentList] of Object.entries(phases)) {
|
||||
if (agentList.length === 0) continue;
|
||||
|
||||
const phaseDuration = agentList.reduce(
|
||||
(sum, agent) => sum + agent.final_duration_ms,
|
||||
0
|
||||
);
|
||||
const phaseDuration = agentList.reduce((sum, agent) => sum + agent.final_duration_ms, 0);
|
||||
|
||||
const phaseCost = agentList.reduce(
|
||||
(sum, agent) => sum + agent.total_cost_usd,
|
||||
0
|
||||
);
|
||||
const phaseCost = agentList.reduce((sum, agent) => sum + agent.total_cost_usd, 0);
|
||||
|
||||
phaseMetrics[phaseName] = {
|
||||
duration_ms: phaseDuration,
|
||||
duration_percentage: calculatePercentage(phaseDuration, totalDuration),
|
||||
cost_usd: phaseCost,
|
||||
agent_count: agentList.length
|
||||
agent_count: agentList.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -312,26 +350,23 @@ export class MetricsTracker {
|
||||
|
||||
/**
|
||||
* Get current metrics
|
||||
* @returns {Object} Current metrics data
|
||||
*/
|
||||
getMetrics() {
|
||||
return JSON.parse(JSON.stringify(this.data));
|
||||
getMetrics(): SessionData {
|
||||
return JSON.parse(JSON.stringify(this.data)) as SessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save metrics to session.json (atomic write)
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async save() {
|
||||
private async save(): Promise<void> {
|
||||
if (!this.data) return;
|
||||
await atomicWrite(this.sessionJsonPath, this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload metrics from disk
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async reload() {
|
||||
this.data = await readJson(this.sessionJsonPath);
|
||||
async reload(): Promise<void> {
|
||||
this.data = await readJson<SessionData>(this.sessionJsonPath);
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,17 @@ const __dirname = path.dirname(__filename);
|
||||
export const SHANNON_ROOT = path.resolve(__dirname, '..', '..');
|
||||
export const AUDIT_LOGS_DIR = path.join(SHANNON_ROOT, 'audit-logs');
|
||||
|
||||
export interface SessionMetadata {
|
||||
id: string;
|
||||
webUrl: string;
|
||||
repoPath?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate standardized session identifier: {hostname}_{sessionId}
|
||||
* @param {Object} sessionMetadata - Session metadata from Shannon store
|
||||
* @param {string} sessionMetadata.id - UUID session ID
|
||||
* @param {string} sessionMetadata.webUrl - Target web URL
|
||||
* @returns {string} Formatted session identifier
|
||||
*/
|
||||
export function generateSessionIdentifier(sessionMetadata) {
|
||||
export function generateSessionIdentifier(sessionMetadata: SessionMetadata): string {
|
||||
const { id, webUrl } = sessionMetadata;
|
||||
const hostname = new URL(webUrl).hostname.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
return `${hostname}_${id}`;
|
||||
@@ -37,23 +40,21 @@ export function generateSessionIdentifier(sessionMetadata) {
|
||||
|
||||
/**
|
||||
* Generate path to audit log directory for a session
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @returns {string} Absolute path to session audit directory
|
||||
*/
|
||||
export function generateAuditPath(sessionMetadata) {
|
||||
export function generateAuditPath(sessionMetadata: SessionMetadata): string {
|
||||
const sessionIdentifier = generateSessionIdentifier(sessionMetadata);
|
||||
return path.join(AUDIT_LOGS_DIR, sessionIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to agent log file
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @param {string} agentName - Name of the agent
|
||||
* @param {number} timestamp - Timestamp (ms since epoch)
|
||||
* @param {number} attemptNumber - Attempt number (1, 2, 3, ...)
|
||||
* @returns {string} Absolute path to agent log file
|
||||
*/
|
||||
export function generateLogPath(sessionMetadata, agentName, timestamp, attemptNumber) {
|
||||
export function generateLogPath(
|
||||
sessionMetadata: SessionMetadata,
|
||||
agentName: string,
|
||||
timestamp: number,
|
||||
attemptNumber: number
|
||||
): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
const filename = `${timestamp}_${agentName}_attempt-${attemptNumber}.log`;
|
||||
return path.join(auditPath, 'agents', filename);
|
||||
@@ -61,36 +62,29 @@ export function generateLogPath(sessionMetadata, agentName, timestamp, attemptNu
|
||||
|
||||
/**
|
||||
* Generate path to prompt snapshot file
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @param {string} agentName - Name of the agent
|
||||
* @returns {string} Absolute path to prompt file
|
||||
*/
|
||||
export function generatePromptPath(sessionMetadata, agentName) {
|
||||
export function generatePromptPath(sessionMetadata: SessionMetadata, agentName: string): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
return path.join(auditPath, 'prompts', `${agentName}.md`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate path to session.json file
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @returns {string} Absolute path to session.json
|
||||
*/
|
||||
export function generateSessionJsonPath(sessionMetadata) {
|
||||
export function generateSessionJsonPath(sessionMetadata: SessionMetadata): string {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
return path.join(auditPath, 'session.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directory exists (idempotent, race-safe)
|
||||
* @param {string} dirPath - Directory path to create
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function ensureDirectory(dirPath) {
|
||||
export async function ensureDirectory(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
} catch (error) {
|
||||
// Ignore EEXIST errors (race condition safe)
|
||||
if (error.code !== 'EEXIST') {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -99,11 +93,8 @@ export async function ensureDirectory(dirPath) {
|
||||
/**
|
||||
* Atomic write using temp file + rename pattern
|
||||
* Guarantees no partial writes or corruption on crash
|
||||
* @param {string} filePath - Target file path
|
||||
* @param {Object|string} data - Data to write (will be JSON.stringified if object)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function atomicWrite(filePath, data) {
|
||||
export async function atomicWrite(filePath: string, data: object | string): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||
|
||||
@@ -117,7 +108,7 @@ export async function atomicWrite(filePath, data) {
|
||||
// Clean up temp file on failure
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch (cleanupError) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
@@ -126,10 +117,8 @@ export async function atomicWrite(filePath, data) {
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable string
|
||||
* @param {number} ms - Duration in milliseconds
|
||||
* @returns {string} Formatted duration (e.g., "2m 34s", "45s", "1.2s")
|
||||
*/
|
||||
export function formatDuration(ms) {
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
@@ -146,40 +135,31 @@ export function formatDuration(ms) {
|
||||
|
||||
/**
|
||||
* Format timestamp to ISO 8601 string
|
||||
* @param {number} [timestamp] - Unix timestamp in ms (defaults to now)
|
||||
* @returns {string} ISO 8601 formatted string
|
||||
*/
|
||||
export function formatTimestamp(timestamp = Date.now()) {
|
||||
export function formatTimestamp(timestamp: number = Date.now()): string {
|
||||
return new Date(timestamp).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentage
|
||||
* @param {number} part - Part value
|
||||
* @param {number} total - Total value
|
||||
* @returns {number} Percentage (0-100)
|
||||
*/
|
||||
export function calculatePercentage(part, total) {
|
||||
export function calculatePercentage(part: number, total: number): number {
|
||||
if (total === 0) return 0;
|
||||
return (part / total) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse JSON file
|
||||
* @param {string} filePath - Path to JSON file
|
||||
* @returns {Promise<Object>} Parsed JSON data
|
||||
*/
|
||||
export async function readJson(filePath) {
|
||||
export async function readJson<T = unknown>(filePath: string): Promise<T> {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
return JSON.parse(content) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists
|
||||
* @param {string} filePath - Path to check
|
||||
* @returns {Promise<boolean>} True if file exists
|
||||
*/
|
||||
export async function fileExists(filePath) {
|
||||
export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
@@ -191,10 +171,8 @@ export async function fileExists(filePath) {
|
||||
/**
|
||||
* Initialize audit directory structure for a session
|
||||
* Creates: audit-logs/{sessionId}/, agents/, prompts/
|
||||
* @param {Object} sessionMetadata - Session metadata
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function initializeAuditStructure(sessionMetadata) {
|
||||
export async function initializeAuditStructure(sessionMetadata: SessionMetadata): Promise<void> {
|
||||
const auditPath = generateAuditPath(sessionMetadata);
|
||||
const agentsPath = path.join(auditPath, 'agents');
|
||||
const promptsPath = path.join(auditPath, 'prompts');
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,18 +7,48 @@
|
||||
import chalk from 'chalk';
|
||||
import {
|
||||
selectSession, deleteSession, deleteAllSessions,
|
||||
validateAgent, validatePhase, reconcileSession
|
||||
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, args, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt) {
|
||||
export async function handleDeveloperCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
pipelineTestingMode: boolean,
|
||||
runClaudePromptWithRetry: RunClaudePromptWithRetry,
|
||||
loadPrompt: LoadPrompt
|
||||
): Promise<void> {
|
||||
try {
|
||||
let session;
|
||||
let session: Session | null;
|
||||
|
||||
// Commands that don't require session selection
|
||||
if (command === '--list-agents') {
|
||||
@@ -55,7 +85,7 @@ export async function handleDeveloperCommand(command, args, pipelineTestingMode,
|
||||
if (command === '--run-phase') {
|
||||
if (!args[0]) {
|
||||
console.log(chalk.red('❌ --run-phase requires a phase name'));
|
||||
console.log(chalk.gray('Usage: ./shannon.mjs --run-phase <phase-name>'));
|
||||
console.log(chalk.gray('Usage: shannon --run-phase <phase-name>'));
|
||||
process.exit(1);
|
||||
}
|
||||
validatePhase(args[0]); // This will throw PentestError if invalid
|
||||
@@ -64,7 +94,7 @@ export async function handleDeveloperCommand(command, args, pipelineTestingMode,
|
||||
if (command === '--rollback-to' || command === '--rerun') {
|
||||
if (!args[0]) {
|
||||
console.log(chalk.red(`❌ ${command} requires an agent name`));
|
||||
console.log(chalk.gray(`Usage: ./shannon.mjs ${command} <agent-name>`));
|
||||
console.log(chalk.gray(`Usage: shannon ${command} <agent-name>`));
|
||||
process.exit(1);
|
||||
}
|
||||
validateAgent(args[0]); // This will throw PentestError if invalid
|
||||
@@ -74,7 +104,8 @@ export async function handleDeveloperCommand(command, args, pipelineTestingMode,
|
||||
try {
|
||||
session = await selectSession();
|
||||
} catch (error) {
|
||||
console.log(chalk.red(`❌ ${error.message}`));
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.red(`❌ ${errMsg}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -94,17 +125,22 @@ export async function handleDeveloperCommand(command, args, pipelineTestingMode,
|
||||
}
|
||||
|
||||
// Reload session after reconciliation to get fresh state
|
||||
const { getSession } = await import('../session-manager.js');
|
||||
session = await getSession(session.id);
|
||||
} catch (error) {
|
||||
// Reconciliation failure is non-critical, but log warning
|
||||
console.log(chalk.yellow(`⚠️ Failed to reconcile session with audit logs: ${error.message}`));
|
||||
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);
|
||||
await runPhase(args[0]!, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
|
||||
break;
|
||||
|
||||
case '--run-all':
|
||||
@@ -112,11 +148,11 @@ export async function handleDeveloperCommand(command, args, pipelineTestingMode,
|
||||
break;
|
||||
|
||||
case '--rollback-to':
|
||||
await rollbackTo(args[0], session);
|
||||
await rollbackTo(args[0]!, session);
|
||||
break;
|
||||
|
||||
case '--rerun':
|
||||
await rerunAgent(args[0], session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
|
||||
await rerunAgent(args[0]!, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
|
||||
break;
|
||||
|
||||
case '--status':
|
||||
@@ -133,11 +169,12 @@ export async function handleDeveloperCommand(command, args, pipelineTestingMode,
|
||||
await logError(error, `Developer command ${command}`);
|
||||
console.log(chalk.red.bold(`\n🚨 Command failed: ${error.message}`));
|
||||
} else {
|
||||
console.log(chalk.red.bold(`\n🚨 Unexpected error: ${error.message}`));
|
||||
if (process.env.DEBUG) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,14 @@
|
||||
|
||||
import { fs, path } from 'zx';
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// Helper function: Validate web URL
|
||||
export function validateWebUrl(url) {
|
||||
export function validateWebUrl(url: string): ValidationResult {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
@@ -17,16 +23,16 @@ export function validateWebUrl(url) {
|
||||
return { valid: false, error: 'Web URL must have a valid hostname' };
|
||||
}
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return { valid: false, error: 'Invalid web URL format' };
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function: Validate local repository path
|
||||
export async function validateRepoPath(repoPath) {
|
||||
export async function validateRepoPath(repoPath: string): Promise<ValidationResult> {
|
||||
try {
|
||||
// Check if path exists
|
||||
if (!await fs.pathExists(repoPath)) {
|
||||
if (!(await fs.pathExists(repoPath))) {
|
||||
return { valid: false, error: 'Repository path does not exist' };
|
||||
}
|
||||
|
||||
@@ -39,7 +45,7 @@ export async function validateRepoPath(repoPath) {
|
||||
// Check if it's readable
|
||||
try {
|
||||
await fs.access(repoPath, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return { valid: false, error: 'Repository path is not readable' };
|
||||
}
|
||||
|
||||
@@ -47,6 +53,7 @@ export async function validateRepoPath(repoPath) {
|
||||
const absolutePath = path.resolve(repoPath);
|
||||
return { valid: true, path: absolutePath };
|
||||
} catch (error) {
|
||||
return { valid: false, error: `Invalid repository path: ${error.message}` };
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
return { valid: false, error: `Invalid repository path: ${errMsg}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,11 @@ import { PentestError } from '../error-handling.js';
|
||||
|
||||
/**
|
||||
* Prompt user for yes/no confirmation
|
||||
* @param {string} message - Question to display
|
||||
* @returns {Promise<boolean>} true if confirmed, false otherwise
|
||||
*/
|
||||
export async function promptConfirmation(message) {
|
||||
export async function promptConfirmation(message: string): Promise<boolean> {
|
||||
const readline = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -29,23 +27,15 @@ export async function promptConfirmation(message) {
|
||||
|
||||
/**
|
||||
* Prompt user to select from numbered list
|
||||
* @param {string} message - Selection prompt
|
||||
* @param {Array} items - Items to choose from
|
||||
* @returns {Promise<any>} Selected item
|
||||
* @throws {PentestError} If invalid selection
|
||||
*/
|
||||
export async function promptSelection(message, items) {
|
||||
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
|
||||
);
|
||||
throw new PentestError('No items available for selection', 'validation', false);
|
||||
}
|
||||
|
||||
const readline = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -54,14 +44,16 @@ export async function promptSelection(message, items) {
|
||||
|
||||
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 }
|
||||
));
|
||||
reject(
|
||||
new PentestError(
|
||||
`Invalid selection. Please enter a number between 1 and ${items.length}`,
|
||||
'validation',
|
||||
false,
|
||||
{ choice: answer }
|
||||
)
|
||||
);
|
||||
} else {
|
||||
resolve(items[choice - 1]);
|
||||
resolve(items[choice - 1]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,67 +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 { displaySplashScreen } from '../splash-screen.js';
|
||||
|
||||
// Helper function: Display help information
|
||||
export function showHelp() {
|
||||
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.mjs <WEB_URL> <REPO_PATH> [--config config.yaml] [--pipeline-testing]');
|
||||
console.log(' ./shannon.mjs <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.mjs --run-phase <phase-name> [--pipeline-testing]');
|
||||
console.log(' ./shannon.mjs --run-all [--pipeline-testing]');
|
||||
console.log(' ./shannon.mjs --rollback-to <agent-name>');
|
||||
console.log(' ./shannon.mjs --rerun <agent-name> [--pipeline-testing]');
|
||||
console.log(' ./shannon.mjs --status');
|
||||
console.log(' ./shannon.mjs --list-agents');
|
||||
console.log(' ./shannon.mjs --cleanup [session-id] # Delete sessions\n');
|
||||
|
||||
console.log(chalk.yellow.bold('OPTIONS:'));
|
||||
console.log(' --config <file> YAML configuration file for authentication and testing parameters');
|
||||
console.log(' --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');
|
||||
|
||||
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(chalk.yellow.bold('EXAMPLES:'));
|
||||
console.log(' # Normal mode - create new session');
|
||||
console.log(' ./shannon.mjs "https://example.com" "/path/to/local/repo"');
|
||||
console.log(' ./shannon.mjs "https://example.com" "/path/to/local/repo" --config auth.yaml');
|
||||
console.log(' ./shannon.mjs "https://example.com" "/path/to/local/repo" --setup-only # Setup only\n');
|
||||
|
||||
console.log(' # Developer mode - operate on existing session');
|
||||
console.log(' ./shannon.mjs --status # Show session status');
|
||||
console.log(' ./shannon.mjs --run-phase exploitation # Run entire phase');
|
||||
console.log(' ./shannon.mjs --run-all # Run all remaining agents');
|
||||
console.log(' ./shannon.mjs --rerun xss-vuln # Fix and rerun failed agent');
|
||||
console.log(' ./shannon.mjs --cleanup # Delete all sessions');
|
||||
console.log(' ./shannon.mjs --cleanup <session-id> # Delete specific session\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(chalk.yellow.bold('ENVIRONMENT VARIABLES:'));
|
||||
console.log(' PENTEST_MAX_RETRIES Number of retries for AI agents (default: 3)');
|
||||
}
|
||||
|
||||
// Export the splash screen function for use in main
|
||||
export { displaySplashScreen };
|
||||
@@ -0,0 +1,81 @@
|
||||
// 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 { displaySplashScreen } from '../splash-screen.js';
|
||||
|
||||
// Helper function: Display help information
|
||||
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('OPTIONS:'));
|
||||
console.log(
|
||||
' --config <file> YAML configuration file for authentication and testing parameters'
|
||||
);
|
||||
console.log(
|
||||
' --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'
|
||||
);
|
||||
|
||||
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(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" --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(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(chalk.yellow.bold('ENVIRONMENT VARIABLES:'));
|
||||
console.log(' PENTEST_MAX_RETRIES Number of retries for AI agents (default: 3)');
|
||||
}
|
||||
|
||||
// Export the splash screen function for use in main
|
||||
export { displaySplashScreen };
|
||||
@@ -4,48 +4,61 @@
|
||||
// it under the terms of the GNU Affero General Public License version 3
|
||||
// as published by the Free Software Foundation.
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import { fs } from 'zx';
|
||||
import yaml from 'js-yaml';
|
||||
import Ajv from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import { Ajv, type ValidateFunction } from 'ajv';
|
||||
import type { FormatsPlugin } from 'ajv-formats';
|
||||
import { PentestError } from './error-handling.js';
|
||||
import type {
|
||||
Config,
|
||||
Rule,
|
||||
Rules,
|
||||
Authentication,
|
||||
DistributedConfig,
|
||||
} from './types/config.js';
|
||||
|
||||
// Handle ESM/CJS interop for ajv-formats using require
|
||||
const require = createRequire(import.meta.url);
|
||||
const addFormats: FormatsPlugin = require('ajv-formats');
|
||||
|
||||
// Initialize AJV with formats
|
||||
const ajv = new Ajv({ allErrors: true, verbose: true });
|
||||
addFormats(ajv);
|
||||
|
||||
// Load JSON Schema
|
||||
let configSchema;
|
||||
let configSchema: object;
|
||||
let validateSchema: ValidateFunction;
|
||||
|
||||
try {
|
||||
const schemaPath = new URL('../configs/config-schema.json', import.meta.url);
|
||||
const schemaContent = await fs.readFile(schemaPath, 'utf8');
|
||||
configSchema = JSON.parse(schemaContent);
|
||||
configSchema = JSON.parse(schemaContent) as object;
|
||||
validateSchema = ajv.compile(configSchema);
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
throw new PentestError(
|
||||
`Failed to load configuration schema: ${error.message}`,
|
||||
`Failed to load configuration schema: ${errMsg}`,
|
||||
'config',
|
||||
false,
|
||||
{ schemaPath: '../configs/config-schema.json', originalError: error.message }
|
||||
{ schemaPath: '../configs/config-schema.json', originalError: errMsg }
|
||||
);
|
||||
}
|
||||
|
||||
// Compile the schema validator
|
||||
const validateSchema = ajv.compile(configSchema);
|
||||
|
||||
// Security patterns to block
|
||||
const DANGEROUS_PATTERNS = [
|
||||
/\.\.\//, // Path traversal
|
||||
/[<>]/, // HTML/XML injection
|
||||
/javascript:/i, // JavaScript URLs
|
||||
/data:/i, // Data URLs
|
||||
/file:/i // File URLs
|
||||
const DANGEROUS_PATTERNS: RegExp[] = [
|
||||
/\.\.\//, // Path traversal
|
||||
/[<>]/, // HTML/XML injection
|
||||
/javascript:/i, // JavaScript URLs
|
||||
/data:/i, // Data URLs
|
||||
/file:/i, // File URLs
|
||||
];
|
||||
|
||||
// Parse and load YAML configuration file with enhanced safety
|
||||
export const parseConfig = async (configPath) => {
|
||||
export const parseConfig = async (configPath: string): Promise<Config> => {
|
||||
try {
|
||||
// File existence check
|
||||
if (!await fs.pathExists(configPath)) {
|
||||
if (!(await fs.pathExists(configPath))) {
|
||||
throw new Error(`Configuration file not found: ${configPath}`);
|
||||
}
|
||||
|
||||
@@ -53,27 +66,30 @@ export const parseConfig = async (configPath) => {
|
||||
const stats = await fs.stat(configPath);
|
||||
const maxFileSize = 1024 * 1024; // 1MB
|
||||
if (stats.size > maxFileSize) {
|
||||
throw new Error(`Configuration file too large: ${stats.size} bytes (maximum: ${maxFileSize} bytes)`);
|
||||
throw new Error(
|
||||
`Configuration file too large: ${stats.size} bytes (maximum: ${maxFileSize} bytes)`
|
||||
);
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const configContent = await fs.readFile(configPath, 'utf8');
|
||||
|
||||
|
||||
// Basic content validation
|
||||
if (!configContent.trim()) {
|
||||
throw new Error('Configuration file is empty');
|
||||
}
|
||||
|
||||
// Parse YAML with safety options
|
||||
let config;
|
||||
let config: unknown;
|
||||
try {
|
||||
config = yaml.load(configContent, {
|
||||
schema: yaml.FAILSAFE_SCHEMA, // Only basic YAML types, no JS evaluation
|
||||
json: false, // Don't allow JSON-specific syntax
|
||||
filename: configPath
|
||||
filename: configPath,
|
||||
});
|
||||
} catch (yamlError) {
|
||||
throw new Error(`YAML parsing failed: ${yamlError.message}`);
|
||||
const errMsg = yamlError instanceof Error ? yamlError.message : String(yamlError);
|
||||
throw new Error(`YAML parsing failed: ${errMsg}`);
|
||||
}
|
||||
|
||||
// Additional safety check
|
||||
@@ -82,26 +98,29 @@ export const parseConfig = async (configPath) => {
|
||||
}
|
||||
|
||||
// Validate the configuration structure and content
|
||||
validateConfig(config);
|
||||
validateConfig(config as Config);
|
||||
|
||||
return config;
|
||||
return config as Config;
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
// Enhance error message with context
|
||||
if (error.message.startsWith('Configuration file not found') ||
|
||||
error.message.startsWith('YAML parsing failed') ||
|
||||
error.message.includes('must be') ||
|
||||
error.message.includes('exceeds maximum')) {
|
||||
if (
|
||||
errMsg.startsWith('Configuration file not found') ||
|
||||
errMsg.startsWith('YAML parsing failed') ||
|
||||
errMsg.includes('must be') ||
|
||||
errMsg.includes('exceeds maximum')
|
||||
) {
|
||||
// These are already well-formatted errors, re-throw as-is
|
||||
throw error;
|
||||
} else {
|
||||
// Wrap other errors with context
|
||||
throw new Error(`Failed to parse configuration file '${configPath}': ${error.message}`);
|
||||
throw new Error(`Failed to parse configuration file '${configPath}': ${errMsg}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Validate overall configuration structure using JSON Schema
|
||||
const validateConfig = (config) => {
|
||||
const validateConfig = (config: Config): void => {
|
||||
// Basic structure validation
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('Configuration must be a valid object');
|
||||
@@ -115,7 +134,7 @@ const validateConfig = (config) => {
|
||||
const isValid = validateSchema(config);
|
||||
if (!isValid) {
|
||||
const errors = validateSchema.errors || [];
|
||||
const errorMessages = errors.map(err => {
|
||||
const errorMessages = errors.map((err) => {
|
||||
const path = err.instancePath || 'root';
|
||||
return `${path}: ${err.message}`;
|
||||
});
|
||||
@@ -132,48 +151,57 @@ const validateConfig = (config) => {
|
||||
|
||||
// Ensure at least some configuration is provided
|
||||
if (!config.rules && !config.authentication) {
|
||||
console.warn('⚠️ Configuration file contains no rules or authentication. The pentest will run without any scoping restrictions or login capabilities.');
|
||||
console.warn(
|
||||
'⚠️ Configuration file contains no rules or authentication. The pentest will run without any scoping restrictions or login capabilities.'
|
||||
);
|
||||
} else if (config.rules && !config.rules.avoid && !config.rules.focus) {
|
||||
console.warn('⚠️ Configuration file contains no rules. The pentest will run without any scoping restrictions.');
|
||||
console.warn(
|
||||
'⚠️ Configuration file contains no rules. The pentest will run without any scoping restrictions.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Perform additional security validation beyond JSON Schema
|
||||
const performSecurityValidation = (config) => {
|
||||
const performSecurityValidation = (config: Config): void => {
|
||||
// Validate authentication section for security issues
|
||||
if (config.authentication) {
|
||||
const auth = config.authentication;
|
||||
|
||||
|
||||
// Check for dangerous patterns in credentials
|
||||
if (auth.credentials) {
|
||||
for (const pattern of DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(auth.credentials.username)) {
|
||||
throw new Error('authentication.credentials.username contains potentially dangerous pattern');
|
||||
throw new Error(
|
||||
'authentication.credentials.username contains potentially dangerous pattern'
|
||||
);
|
||||
}
|
||||
if (pattern.test(auth.credentials.password)) {
|
||||
throw new Error('authentication.credentials.password contains potentially dangerous pattern');
|
||||
throw new Error(
|
||||
'authentication.credentials.password contains potentially dangerous pattern'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check login flow for dangerous patterns
|
||||
if (auth.login_flow) {
|
||||
auth.login_flow.forEach((step, index) => {
|
||||
for (const pattern of DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(step)) {
|
||||
throw new Error(`authentication.login_flow[${index}] contains potentially dangerous pattern: ${pattern.source}`);
|
||||
throw new Error(
|
||||
`authentication.login_flow[${index}] contains potentially dangerous pattern: ${pattern.source}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Validate rules section for security issues
|
||||
if (config.rules) {
|
||||
validateRulesSecurity(config.rules.avoid, 'avoid');
|
||||
validateRulesSecurity(config.rules.focus, 'focus');
|
||||
|
||||
|
||||
// Check for duplicate and conflicting rules
|
||||
checkForDuplicates(config.rules.avoid || [], 'avoid');
|
||||
checkForDuplicates(config.rules.focus || [], 'focus');
|
||||
@@ -182,132 +210,148 @@ const performSecurityValidation = (config) => {
|
||||
};
|
||||
|
||||
// Validate rules for security issues
|
||||
const validateRulesSecurity = (rules, ruleType) => {
|
||||
const validateRulesSecurity = (rules: Rule[] | undefined, ruleType: string): void => {
|
||||
if (!rules) return;
|
||||
|
||||
|
||||
rules.forEach((rule, index) => {
|
||||
// Security validation
|
||||
for (const pattern of DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(rule.url_path)) {
|
||||
throw new Error(`rules.${ruleType}[${index}].url_path contains potentially dangerous pattern: ${pattern.source}`);
|
||||
throw new Error(
|
||||
`rules.${ruleType}[${index}].url_path contains potentially dangerous pattern: ${pattern.source}`
|
||||
);
|
||||
}
|
||||
if (pattern.test(rule.description)) {
|
||||
throw new Error(`rules.${ruleType}[${index}].description contains potentially dangerous pattern: ${pattern.source}`);
|
||||
throw new Error(
|
||||
`rules.${ruleType}[${index}].description contains potentially dangerous pattern: ${pattern.source}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Type-specific validation
|
||||
validateRuleTypeSpecific(rule, ruleType, index);
|
||||
});
|
||||
};
|
||||
|
||||
// Validate rule based on its specific type
|
||||
const validateRuleTypeSpecific = (rule, ruleType, index) => {
|
||||
const validateRuleTypeSpecific = (rule: Rule, ruleType: string, index: number): void => {
|
||||
switch (rule.type) {
|
||||
case 'path':
|
||||
if (!rule.url_path.startsWith('/')) {
|
||||
throw new Error(`rules.${ruleType}[${index}].url_path for type 'path' must start with '/'`);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case 'subdomain':
|
||||
case 'domain':
|
||||
// Basic domain validation - no slashes allowed
|
||||
if (rule.url_path.includes('/')) {
|
||||
throw new Error(`rules.${ruleType}[${index}].url_path for type '${rule.type}' cannot contain '/' characters`);
|
||||
throw new Error(
|
||||
`rules.${ruleType}[${index}].url_path for type '${rule.type}' cannot contain '/' characters`
|
||||
);
|
||||
}
|
||||
// Must contain at least one dot for domains
|
||||
if (rule.type === 'domain' && !rule.url_path.includes('.')) {
|
||||
throw new Error(`rules.${ruleType}[${index}].url_path for type 'domain' must be a valid domain name`);
|
||||
throw new Error(
|
||||
`rules.${ruleType}[${index}].url_path for type 'domain' must be a valid domain name`
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'method':
|
||||
|
||||
case 'method': {
|
||||
const allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
||||
if (!allowedMethods.includes(rule.url_path.toUpperCase())) {
|
||||
throw new Error(`rules.${ruleType}[${index}].url_path for type 'method' must be one of: ${allowedMethods.join(', ')}`);
|
||||
throw new Error(
|
||||
`rules.${ruleType}[${index}].url_path for type 'method' must be one of: ${allowedMethods.join(', ')}`
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
case 'header':
|
||||
// Header name validation (basic)
|
||||
if (!rule.url_path.match(/^[a-zA-Z0-9\-_]+$/)) {
|
||||
throw new Error(`rules.${ruleType}[${index}].url_path for type 'header' must be a valid header name (alphanumeric, hyphens, underscores only)`);
|
||||
throw new Error(
|
||||
`rules.${ruleType}[${index}].url_path for type 'header' must be a valid header name (alphanumeric, hyphens, underscores only)`
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case 'parameter':
|
||||
// Parameter name validation (basic)
|
||||
if (!rule.url_path.match(/^[a-zA-Z0-9\-_]+$/)) {
|
||||
throw new Error(`rules.${ruleType}[${index}].url_path for type 'parameter' must be a valid parameter name (alphanumeric, hyphens, underscores only)`);
|
||||
throw new Error(
|
||||
`rules.${ruleType}[${index}].url_path for type 'parameter' must be a valid parameter name (alphanumeric, hyphens, underscores only)`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Check for duplicate rules
|
||||
const checkForDuplicates = (rules, ruleType) => {
|
||||
const seen = new Set();
|
||||
const checkForDuplicates = (rules: Rule[], ruleType: string): void => {
|
||||
const seen = new Set<string>();
|
||||
rules.forEach((rule, index) => {
|
||||
const key = `${rule.type}:${rule.url_path}`;
|
||||
if (seen.has(key)) {
|
||||
throw new Error(`Duplicate rule found in rules.${ruleType}[${index}]: ${rule.type} '${rule.url_path}'`);
|
||||
throw new Error(
|
||||
`Duplicate rule found in rules.${ruleType}[${index}]: ${rule.type} '${rule.url_path}'`
|
||||
);
|
||||
}
|
||||
seen.add(key);
|
||||
});
|
||||
};
|
||||
|
||||
// Check for conflicting rules between avoid and focus
|
||||
const checkForConflicts = (avoidRules = [], focusRules = []) => {
|
||||
const avoidSet = new Set(avoidRules.map(rule => `${rule.type}:${rule.url_path}`));
|
||||
|
||||
const checkForConflicts = (avoidRules: Rule[] = [], focusRules: Rule[] = []): void => {
|
||||
const avoidSet = new Set(avoidRules.map((rule) => `${rule.type}:${rule.url_path}`));
|
||||
|
||||
focusRules.forEach((rule, index) => {
|
||||
const key = `${rule.type}:${rule.url_path}`;
|
||||
if (avoidSet.has(key)) {
|
||||
throw new Error(`Conflicting rule found: rules.focus[${index}] '${rule.url_path}' also exists in rules.avoid`);
|
||||
throw new Error(
|
||||
`Conflicting rule found: rules.focus[${index}] '${rule.url_path}' also exists in rules.avoid`
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Sanitize and normalize rule values
|
||||
const sanitizeRule = (rule) => {
|
||||
const sanitizeRule = (rule: Rule): Rule => {
|
||||
return {
|
||||
description: rule.description.trim(),
|
||||
type: rule.type.toLowerCase().trim(),
|
||||
url_path: rule.url_path.trim()
|
||||
type: rule.type.toLowerCase().trim() as Rule['type'],
|
||||
url_path: rule.url_path.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
// Distribute configuration sections to different agents with sanitization
|
||||
export const distributeConfig = (config) => {
|
||||
export const distributeConfig = (config: Config | null): DistributedConfig => {
|
||||
const avoid = config?.rules?.avoid || [];
|
||||
const focus = config?.rules?.focus || [];
|
||||
const authentication = config?.authentication || null;
|
||||
|
||||
|
||||
return {
|
||||
avoid: avoid.map(sanitizeRule),
|
||||
focus: focus.map(sanitizeRule),
|
||||
authentication: authentication ? sanitizeAuthentication(authentication) : null
|
||||
authentication: authentication ? sanitizeAuthentication(authentication) : null,
|
||||
};
|
||||
};
|
||||
|
||||
// Sanitize and normalize authentication values
|
||||
const sanitizeAuthentication = (auth) => {
|
||||
const sanitizeAuthentication = (auth: Authentication): Authentication => {
|
||||
return {
|
||||
login_type: auth.login_type.toLowerCase().trim(),
|
||||
login_type: auth.login_type.toLowerCase().trim() as Authentication['login_type'],
|
||||
login_url: auth.login_url.trim(),
|
||||
credentials: {
|
||||
username: auth.credentials.username.trim(),
|
||||
password: auth.credentials.password,
|
||||
...(auth.credentials.totp_secret && { totp_secret: auth.credentials.totp_secret.trim() })
|
||||
...(auth.credentials.totp_secret && { totp_secret: auth.credentials.totp_secret.trim() }),
|
||||
},
|
||||
login_flow: auth.login_flow.map(step => step.trim()),
|
||||
login_flow: auth.login_flow.map((step) => step.trim()),
|
||||
success_condition: {
|
||||
type: auth.success_condition.type.toLowerCase().trim(),
|
||||
value: auth.success_condition.value.trim()
|
||||
}
|
||||
type: auth.success_condition.type.toLowerCase().trim() as Authentication['success_condition']['type'],
|
||||
value: auth.success_condition.value.trim(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Additional validation functions are already exported above
|
||||
|
||||
@@ -6,38 +6,40 @@
|
||||
|
||||
import { path, fs } from 'zx';
|
||||
import chalk from 'chalk';
|
||||
import { validateQueueAndDeliverable } from './queue-validation.js';
|
||||
import { validateQueueAndDeliverable, type VulnType } from './queue-validation.js';
|
||||
import type { AgentName, PromptName, PlaywrightAgent, AgentValidator } from './types/agents.js';
|
||||
|
||||
// Factory function for vulnerability queue validators
|
||||
function createVulnValidator(vulnType) {
|
||||
return async (sourceDir) => {
|
||||
function createVulnValidator(vulnType: VulnType): AgentValidator {
|
||||
return async (sourceDir: string): Promise<boolean> => {
|
||||
try {
|
||||
await validateQueueAndDeliverable(vulnType, sourceDir);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` Queue validation failed for ${vulnType}: ${error.message}`));
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.yellow(` Queue validation failed for ${vulnType}: ${errMsg}`));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Factory function for exploit deliverable validators
|
||||
function createExploitValidator(vulnType) {
|
||||
return async (sourceDir) => {
|
||||
function createExploitValidator(vulnType: VulnType): AgentValidator {
|
||||
return async (sourceDir: string): Promise<boolean> => {
|
||||
const evidenceFile = path.join(sourceDir, 'deliverables', `${vulnType}_exploitation_evidence.md`);
|
||||
return await fs.pathExists(evidenceFile);
|
||||
};
|
||||
}
|
||||
|
||||
// MCP agent mapping - assigns each agent to a specific Playwright instance to prevent conflicts
|
||||
export const MCP_AGENT_MAPPING = Object.freeze({
|
||||
export const MCP_AGENT_MAPPING: Record<PromptName, PlaywrightAgent> = Object.freeze({
|
||||
// Phase 1: Pre-reconnaissance (actual prompt name is 'pre-recon-code')
|
||||
// NOTE: Pre-recon is pure code analysis and doesn't use browser automation,
|
||||
// but assigning MCP server anyway for consistency and future extensibility
|
||||
'pre-recon-code': 'playwright-agent1',
|
||||
|
||||
// Phase 2: Reconnaissance (actual prompt name is 'recon')
|
||||
'recon': 'playwright-agent2',
|
||||
recon: 'playwright-agent2',
|
||||
|
||||
// Phase 3: Vulnerability Analysis (5 parallel agents)
|
||||
'vuln-injection': 'playwright-agent1',
|
||||
@@ -56,19 +58,19 @@ export const MCP_AGENT_MAPPING = Object.freeze({
|
||||
// Phase 5: Reporting (actual prompt name is 'report-executive')
|
||||
// NOTE: Report generation is typically text-based and doesn't use browser automation,
|
||||
// but assigning MCP server anyway for potential screenshot inclusion or future needs
|
||||
'report-executive': 'playwright-agent3'
|
||||
'report-executive': 'playwright-agent3',
|
||||
});
|
||||
|
||||
// Direct agent-to-validator mapping - much simpler than pattern matching
|
||||
export const AGENT_VALIDATORS = Object.freeze({
|
||||
export const AGENT_VALIDATORS: Record<AgentName, AgentValidator> = Object.freeze({
|
||||
// Pre-reconnaissance agent - validates the code analysis deliverable created by the agent
|
||||
'pre-recon': async (sourceDir) => {
|
||||
'pre-recon': async (sourceDir: string): Promise<boolean> => {
|
||||
const codeAnalysisFile = path.join(sourceDir, 'deliverables', 'code_analysis_deliverable.md');
|
||||
return await fs.pathExists(codeAnalysisFile);
|
||||
},
|
||||
|
||||
// Reconnaissance agent
|
||||
'recon': async (sourceDir) => {
|
||||
recon: async (sourceDir: string): Promise<boolean> => {
|
||||
const reconFile = path.join(sourceDir, 'deliverables', 'recon_deliverable.md');
|
||||
return await fs.pathExists(reconFile);
|
||||
},
|
||||
@@ -88,15 +90,21 @@ export const AGENT_VALIDATORS = Object.freeze({
|
||||
'authz-exploit': createExploitValidator('authz'),
|
||||
|
||||
// Executive report agent
|
||||
'report': async (sourceDir) => {
|
||||
const reportFile = path.join(sourceDir, 'deliverables', 'comprehensive_security_assessment_report.md');
|
||||
report: async (sourceDir: string): Promise<boolean> => {
|
||||
const reportFile = path.join(
|
||||
sourceDir,
|
||||
'deliverables',
|
||||
'comprehensive_security_assessment_report.md'
|
||||
);
|
||||
|
||||
const reportExists = await fs.pathExists(reportFile);
|
||||
|
||||
if (!reportExists) {
|
||||
console.log(chalk.red(` ❌ Missing required deliverable: comprehensive_security_assessment_report.md`));
|
||||
console.log(
|
||||
chalk.red(` ❌ Missing required deliverable: comprehensive_security_assessment_report.md`)
|
||||
);
|
||||
}
|
||||
|
||||
return reportExists;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -6,13 +6,30 @@
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { fs, path } from 'zx';
|
||||
import type {
|
||||
PentestErrorType,
|
||||
PentestErrorContext,
|
||||
LogEntry,
|
||||
ToolErrorResult,
|
||||
PromptErrorResult,
|
||||
} from './types/errors.js';
|
||||
|
||||
// Custom error class for pentest operations
|
||||
export class PentestError extends Error {
|
||||
constructor(message, type, retryable = false, context = {}) {
|
||||
name = 'PentestError' as const;
|
||||
type: PentestErrorType;
|
||||
retryable: boolean;
|
||||
context: PentestErrorContext;
|
||||
timestamp: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
type: PentestErrorType,
|
||||
retryable: boolean = false,
|
||||
context: PentestErrorContext = {}
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PentestError';
|
||||
this.type = type; // 'config', 'network', 'tool', 'prompt', 'filesystem', 'validation'
|
||||
this.type = type;
|
||||
this.retryable = retryable;
|
||||
this.context = context;
|
||||
this.timestamp = new Date().toISOString();
|
||||
@@ -20,9 +37,13 @@ export class PentestError extends Error {
|
||||
}
|
||||
|
||||
// Centralized error logging function
|
||||
export const logError = async (error, contextMsg, sourceDir = null) => {
|
||||
export const logError = async (
|
||||
error: Error & { type?: PentestErrorType; retryable?: boolean; context?: PentestErrorContext },
|
||||
contextMsg: string,
|
||||
sourceDir: string | null = null
|
||||
): Promise<LogEntry> => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = {
|
||||
const logEntry: LogEntry = {
|
||||
timestamp,
|
||||
context: contextMsg,
|
||||
error: {
|
||||
@@ -30,41 +51,51 @@ export const logError = async (error, contextMsg, sourceDir = null) => {
|
||||
message: error.message,
|
||||
type: error.type || 'unknown',
|
||||
retryable: error.retryable || false,
|
||||
stack: error.stack
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Only add stack if it exists
|
||||
if (error.stack) {
|
||||
logEntry.error.stack = error.stack;
|
||||
}
|
||||
|
||||
// Console logging with color
|
||||
const prefix = error.retryable ? '⚠️' : '❌';
|
||||
const color = error.retryable ? chalk.yellow : chalk.red;
|
||||
console.log(color(`${prefix} ${contextMsg}:`));
|
||||
console.log(color(` ${error.message}`));
|
||||
|
||||
|
||||
if (error.context && Object.keys(error.context).length > 0) {
|
||||
console.log(chalk.gray(` Context: ${JSON.stringify(error.context)}`));
|
||||
}
|
||||
|
||||
|
||||
// File logging (if source directory available)
|
||||
if (sourceDir) {
|
||||
try {
|
||||
const logPath = path.join(sourceDir, 'error.log');
|
||||
await fs.appendFile(logPath, JSON.stringify(logEntry) + '\n');
|
||||
} catch (logErr) {
|
||||
console.log(chalk.gray(` (Failed to write error log: ${logErr.message})`));
|
||||
const errMsg = logErr instanceof Error ? logErr.message : String(logErr);
|
||||
console.log(chalk.gray(` (Failed to write error log: ${errMsg})`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return logEntry;
|
||||
};
|
||||
|
||||
// Handle tool execution errors
|
||||
export const handleToolError = (toolName, error) => {
|
||||
const isRetryable = error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND';
|
||||
|
||||
return {
|
||||
tool: toolName,
|
||||
output: `Error: ${error.message}`,
|
||||
status: 'error',
|
||||
export const handleToolError = (
|
||||
toolName: string,
|
||||
error: Error & { code?: string }
|
||||
): ToolErrorResult => {
|
||||
const isRetryable =
|
||||
error.code === 'ECONNRESET' ||
|
||||
error.code === 'ETIMEDOUT' ||
|
||||
error.code === 'ENOTFOUND';
|
||||
|
||||
return {
|
||||
tool: toolName,
|
||||
output: `Error: ${error.message}`,
|
||||
status: 'error',
|
||||
duration: 0,
|
||||
success: false,
|
||||
error: new PentestError(
|
||||
@@ -72,12 +103,15 @@ export const handleToolError = (toolName, error) => {
|
||||
'tool',
|
||||
isRetryable,
|
||||
{ toolName, originalError: error.message, errorCode: error.code }
|
||||
)
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// Handle prompt loading errors
|
||||
export const handlePromptError = (promptName, error) => {
|
||||
export const handlePromptError = (
|
||||
promptName: string,
|
||||
error: Error
|
||||
): PromptErrorResult => {
|
||||
return {
|
||||
success: false,
|
||||
error: new PentestError(
|
||||
@@ -85,81 +119,89 @@ export const handlePromptError = (promptName, error) => {
|
||||
'prompt',
|
||||
false,
|
||||
{ promptName, originalError: error.message }
|
||||
)
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// Check if an error should trigger a retry for Claude agents
|
||||
export const isRetryableError = (error) => {
|
||||
export const isRetryableError = (error: Error): boolean => {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
|
||||
// Network and connection errors - always retryable
|
||||
if (message.includes('network') ||
|
||||
message.includes('connection') ||
|
||||
message.includes('timeout') ||
|
||||
message.includes('econnreset') ||
|
||||
message.includes('enotfound') ||
|
||||
message.includes('econnrefused')) {
|
||||
if (
|
||||
message.includes('network') ||
|
||||
message.includes('connection') ||
|
||||
message.includes('timeout') ||
|
||||
message.includes('econnreset') ||
|
||||
message.includes('enotfound') ||
|
||||
message.includes('econnrefused')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Rate limiting - retryable with longer backoff
|
||||
if (message.includes('rate limit') ||
|
||||
message.includes('429') ||
|
||||
message.includes('too many requests')) {
|
||||
if (
|
||||
message.includes('rate limit') ||
|
||||
message.includes('429') ||
|
||||
message.includes('too many requests')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Server errors - retryable
|
||||
if (message.includes('server error') ||
|
||||
message.includes('5xx') ||
|
||||
message.includes('internal server error') ||
|
||||
message.includes('service unavailable') ||
|
||||
message.includes('bad gateway')) {
|
||||
if (
|
||||
message.includes('server error') ||
|
||||
message.includes('5xx') ||
|
||||
message.includes('internal server error') ||
|
||||
message.includes('service unavailable') ||
|
||||
message.includes('bad gateway')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Claude API specific errors - retryable
|
||||
if (message.includes('mcp server') ||
|
||||
message.includes('model unavailable') ||
|
||||
message.includes('service temporarily unavailable') ||
|
||||
message.includes('api error') ||
|
||||
message.includes('terminated')) {
|
||||
if (
|
||||
message.includes('mcp server') ||
|
||||
message.includes('model unavailable') ||
|
||||
message.includes('service temporarily unavailable') ||
|
||||
message.includes('api error') ||
|
||||
message.includes('terminated')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Max turns without completion - retryable once
|
||||
if (message.includes('max turns') ||
|
||||
message.includes('maximum turns')) {
|
||||
if (message.includes('max turns') || message.includes('maximum turns')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Non-retryable errors
|
||||
if (message.includes('authentication') ||
|
||||
message.includes('invalid prompt') ||
|
||||
message.includes('out of memory') ||
|
||||
message.includes('permission denied') ||
|
||||
message.includes('session limit reached') ||
|
||||
message.includes('invalid api key')) {
|
||||
if (
|
||||
message.includes('authentication') ||
|
||||
message.includes('invalid prompt') ||
|
||||
message.includes('out of memory') ||
|
||||
message.includes('permission denied') ||
|
||||
message.includes('session limit reached') ||
|
||||
message.includes('invalid api key')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Default to non-retryable for unknown errors
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get retry delay based on error type and attempt number
|
||||
export const getRetryDelay = (error, attempt) => {
|
||||
export const getRetryDelay = (error: Error, attempt: number): number => {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
|
||||
// Rate limiting gets longer delays
|
||||
if (message.includes('rate limit') || message.includes('429')) {
|
||||
return Math.min(30000 + (attempt * 10000), 120000); // 30s, 40s, 50s, max 2min
|
||||
return Math.min(30000 + attempt * 10000, 120000); // 30s, 40s, 50s, max 2min
|
||||
}
|
||||
|
||||
|
||||
// Exponential backoff with jitter for other retryable errors
|
||||
const baseDelay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
|
||||
const jitter = Math.random() * 1000; // 0-1s random
|
||||
return Math.min(baseDelay + jitter, 30000); // Max 30s
|
||||
};
|
||||
};
|
||||
@@ -12,14 +12,51 @@ import { handleToolError, PentestError } from '../error-handling.js';
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
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';
|
||||
|
||||
type ToolName = 'nmap' | 'subfinder' | 'whatweb' | 'schemathesis';
|
||||
type ToolStatus = 'success' | 'skipped' | 'error';
|
||||
|
||||
interface TerminalScanResult {
|
||||
tool: ToolName;
|
||||
output: string;
|
||||
status: ToolStatus;
|
||||
duration: number;
|
||||
success?: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface PromptVariables {
|
||||
webUrl: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
interface Wave1Results {
|
||||
nmap: TerminalScanResult | string | AgentResult;
|
||||
subfinder: TerminalScanResult | string | AgentResult;
|
||||
whatweb: TerminalScanResult | string | AgentResult;
|
||||
naabu?: TerminalScanResult | string | AgentResult;
|
||||
codeAnalysis: AgentResult;
|
||||
}
|
||||
|
||||
interface Wave2Results {
|
||||
schemathesis: TerminalScanResult;
|
||||
}
|
||||
|
||||
interface PreReconResult {
|
||||
duration: number;
|
||||
report: string;
|
||||
}
|
||||
|
||||
// Pure function: Run terminal scanning tools
|
||||
async function runTerminalScan(tool, target, sourceDir = null) {
|
||||
async function runTerminalScan(tool: ToolName, target: string, sourceDir: string | null = null): Promise<TerminalScanResult> {
|
||||
const timer = new Timer(`command-${tool}`);
|
||||
try {
|
||||
let command, result;
|
||||
let result;
|
||||
switch (tool) {
|
||||
case 'nmap':
|
||||
case 'nmap': {
|
||||
console.log(chalk.blue(` 🔍 Running ${tool} scan...`));
|
||||
const nmapHostname = new URL(target).hostname;
|
||||
result = await $({ silent: true, stdio: ['ignore', 'pipe', 'ignore'] })`nmap -sV -sC ${nmapHostname}`;
|
||||
@@ -27,7 +64,8 @@ async function runTerminalScan(tool, target, sourceDir = null) {
|
||||
timingResults.commands[tool] = duration;
|
||||
console.log(chalk.green(` ✅ ${tool} completed in ${formatDuration(duration)}`));
|
||||
return { tool: 'nmap', output: result.stdout, status: 'success', duration };
|
||||
case 'subfinder':
|
||||
}
|
||||
case 'subfinder': {
|
||||
console.log(chalk.blue(` 🔍 Running ${tool} scan...`));
|
||||
const hostname = new URL(target).hostname;
|
||||
result = await $({ silent: true, stdio: ['ignore', 'pipe', 'ignore'] })`subfinder -d ${hostname}`;
|
||||
@@ -35,24 +73,26 @@ async function runTerminalScan(tool, target, sourceDir = null) {
|
||||
timingResults.commands[tool] = subfinderDuration;
|
||||
console.log(chalk.green(` ✅ ${tool} completed in ${formatDuration(subfinderDuration)}`));
|
||||
return { tool: 'subfinder', output: result.stdout, status: 'success', duration: subfinderDuration };
|
||||
case 'whatweb':
|
||||
}
|
||||
case 'whatweb': {
|
||||
console.log(chalk.blue(` 🔍 Running ${tool} scan...`));
|
||||
command = `whatweb --open-timeout 30 --read-timeout 60 ${target}`;
|
||||
const command = `whatweb --open-timeout 30 --read-timeout 60 ${target}`;
|
||||
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 };
|
||||
case 'schemathesis':
|
||||
}
|
||||
case 'schemathesis': {
|
||||
// Only run if API schemas found
|
||||
const schemasDir = path.join(sourceDir || '.', 'outputs', 'schemas');
|
||||
if (await fs.pathExists(schemasDir)) {
|
||||
const schemaFiles = await fs.readdir(schemasDir);
|
||||
const apiSchemas = schemaFiles.filter(f => f.endsWith('.json') || f.endsWith('.yml') || f.endsWith('.yaml'));
|
||||
const schemaFiles = await fs.readdir(schemasDir) as string[];
|
||||
const apiSchemas = schemaFiles.filter((f: string) => f.endsWith('.json') || f.endsWith('.yml') || f.endsWith('.yaml'));
|
||||
if (apiSchemas.length > 0) {
|
||||
console.log(chalk.blue(` 🔍 Running ${tool} scan...`));
|
||||
let allResults = [];
|
||||
const allResults: string[] = [];
|
||||
|
||||
// Run schemathesis on each schema file
|
||||
for (const schemaFile of apiSchemas) {
|
||||
@@ -61,7 +101,8 @@ async function runTerminalScan(tool, target, sourceDir = null) {
|
||||
result = await $({ silent: true, stdio: ['ignore', 'pipe', 'ignore'] })`schemathesis run ${schemaPath} -u ${target} --max-failures=5`;
|
||||
allResults.push(`Schema: ${schemaFile}\n${result.stdout}`);
|
||||
} catch (schemaError) {
|
||||
allResults.push(`Schema: ${schemaFile}\nError: ${schemaError.stdout || schemaError.message}`);
|
||||
const err = schemaError as { stdout?: string; message?: string };
|
||||
allResults.push(`Schema: ${schemaFile}\nError: ${err.stdout || err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +118,7 @@ async function runTerminalScan(tool, target, sourceDir = null) {
|
||||
console.log(chalk.gray(` ⏭️ ${tool} - schemas directory not found`));
|
||||
return { tool: 'schemathesis', output: 'Schemas directory not found', status: 'skipped', duration: timer.stop() };
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${tool}`);
|
||||
}
|
||||
@@ -84,15 +126,22 @@ async function runTerminalScan(tool, target, sourceDir = null) {
|
||||
const duration = timer.stop();
|
||||
timingResults.commands[tool] = duration;
|
||||
console.log(chalk.red(` ❌ ${tool} failed in ${formatDuration(duration)}`));
|
||||
return handleToolError(tool, error);
|
||||
return handleToolError(tool, error as Error & { code?: string }) as TerminalScanResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Wave 1: Initial footprinting + authentication
|
||||
async function runPreReconWave1(webUrl, sourceDir, variables, config, pipelineTestingMode = false, sessionId = null) {
|
||||
async function runPreReconWave1(
|
||||
webUrl: string,
|
||||
sourceDir: string,
|
||||
variables: PromptVariables,
|
||||
config: DistributedConfig | null,
|
||||
pipelineTestingMode: boolean = false,
|
||||
sessionId: string | null = null
|
||||
): Promise<Wave1Results> {
|
||||
console.log(chalk.blue(' → Launching Wave 1 operations in parallel...'));
|
||||
|
||||
const operations = [];
|
||||
const operations: Promise<TerminalScanResult | AgentResult>[] = [];
|
||||
|
||||
// Skip external commands in pipeline testing mode
|
||||
if (pipelineTestingMode) {
|
||||
@@ -106,7 +155,7 @@ async function runPreReconWave1(webUrl, sourceDir, variables, config, pipelineTe
|
||||
AGENTS['pre-recon'].displayName,
|
||||
'pre-recon', // Agent name for snapshot creation
|
||||
chalk.cyan,
|
||||
{ id: sessionId, webUrl } // Session metadata for audit logging (STANDARD: use 'id' field)
|
||||
{ id: sessionId!, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field)
|
||||
)
|
||||
);
|
||||
const [codeAnalysis] = await Promise.all(operations);
|
||||
@@ -114,8 +163,7 @@ async function runPreReconWave1(webUrl, sourceDir, variables, config, pipelineTe
|
||||
nmap: 'Skipped (pipeline testing mode)',
|
||||
subfinder: 'Skipped (pipeline testing mode)',
|
||||
whatweb: 'Skipped (pipeline testing mode)',
|
||||
|
||||
codeAnalysis
|
||||
codeAnalysis: codeAnalysis as AgentResult
|
||||
};
|
||||
} else {
|
||||
operations.push(
|
||||
@@ -130,7 +178,7 @@ async function runPreReconWave1(webUrl, sourceDir, variables, config, pipelineTe
|
||||
AGENTS['pre-recon'].displayName,
|
||||
'pre-recon', // Agent name for snapshot creation
|
||||
chalk.cyan,
|
||||
{ id: sessionId, webUrl } // Session metadata for audit logging (STANDARD: use 'id' field)
|
||||
{ id: sessionId!, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -138,13 +186,23 @@ async function runPreReconWave1(webUrl, sourceDir, variables, config, pipelineTe
|
||||
// Check if authentication config is provided for login instructions injection
|
||||
console.log(chalk.gray(` → Config check: ${config ? 'present' : 'missing'}, Auth: ${config?.authentication ? 'present' : 'missing'}`));
|
||||
|
||||
const [nmap, subfinder, whatweb, naabu, codeAnalysis] = await Promise.all(operations);
|
||||
const [nmap, subfinder, whatweb, codeAnalysis] = await Promise.all(operations);
|
||||
|
||||
return { nmap, subfinder, whatweb, naabu, codeAnalysis };
|
||||
return {
|
||||
nmap: nmap as TerminalScanResult,
|
||||
subfinder: subfinder as TerminalScanResult,
|
||||
whatweb: whatweb as TerminalScanResult,
|
||||
codeAnalysis: codeAnalysis as AgentResult
|
||||
};
|
||||
}
|
||||
|
||||
// Wave 2: Additional scanning
|
||||
async function runPreReconWave2(webUrl, sourceDir, toolAvailability, pipelineTestingMode = false) {
|
||||
async function runPreReconWave2(
|
||||
webUrl: string,
|
||||
sourceDir: string,
|
||||
toolAvailability: ToolAvailability,
|
||||
pipelineTestingMode: boolean = false
|
||||
): Promise<Wave2Results> {
|
||||
console.log(chalk.blue(' → Running Wave 2 additional scans in parallel...'));
|
||||
|
||||
// Skip external commands in pipeline testing mode
|
||||
@@ -155,7 +213,7 @@ async function runPreReconWave2(webUrl, sourceDir, toolAvailability, pipelineTes
|
||||
};
|
||||
}
|
||||
|
||||
const operations = [];
|
||||
const operations: Promise<TerminalScanResult>[] = [];
|
||||
|
||||
// Parallel additional scans (only run if tools are available)
|
||||
|
||||
@@ -175,21 +233,29 @@ async function runPreReconWave2(webUrl, sourceDir, toolAvailability, pipelineTes
|
||||
const results = await Promise.all(operations);
|
||||
|
||||
// Map results back to named properties
|
||||
const response = {};
|
||||
const response: Wave2Results = {
|
||||
schemathesis: { tool: 'schemathesis', output: 'Tool not available', status: 'skipped', duration: 0 }
|
||||
};
|
||||
let resultIndex = 0;
|
||||
|
||||
if (toolAvailability.schemathesis) {
|
||||
response.schemathesis = results[resultIndex++];
|
||||
response.schemathesis = results[resultIndex++]!;
|
||||
} else {
|
||||
console.log(chalk.gray(' ⏭️ schemathesis - tool not available'));
|
||||
response.schemathesis = { tool: 'schemathesis', output: 'Tool not available', status: 'skipped', duration: 0 };
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Helper type for stitching results
|
||||
interface StitchableResult {
|
||||
status?: string;
|
||||
output?: string;
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
// Pure function: Stitch together pre-recon outputs and save to file
|
||||
async function stitchPreReconOutputs(outputs, sourceDir) {
|
||||
async function stitchPreReconOutputs(outputs: (StitchableResult | string | undefined)[], sourceDir: string): Promise<string> {
|
||||
const [nmap, subfinder, whatweb, naabu, codeAnalysis, ...additionalScans] = outputs;
|
||||
|
||||
// Try to read the code analysis deliverable file
|
||||
@@ -198,7 +264,8 @@ async function stitchPreReconOutputs(outputs, sourceDir) {
|
||||
const codeAnalysisPath = path.join(sourceDir, 'deliverables', 'code_analysis_deliverable.md');
|
||||
codeAnalysisContent = await fs.readFile(codeAnalysisPath, 'utf8');
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(`⚠️ Could not read code analysis deliverable: ${error.message}`));
|
||||
const err = error as Error;
|
||||
console.log(chalk.yellow(`⚠️ Could not read code analysis deliverable: ${err.message}`));
|
||||
// Fallback message if file doesn't exist
|
||||
codeAnalysisContent = 'Analysis located in deliverables/code_analysis_deliverable.md';
|
||||
}
|
||||
@@ -209,34 +276,52 @@ async function stitchPreReconOutputs(outputs, sourceDir) {
|
||||
if (additionalScans && additionalScans.length > 0) {
|
||||
additionalSection = '\n## Authenticated Scans\n';
|
||||
additionalScans.forEach(scan => {
|
||||
if (scan && scan.tool) {
|
||||
const s = scan as StitchableResult;
|
||||
if (s && s.tool) {
|
||||
additionalSection += `
|
||||
### ${scan.tool.toUpperCase()}
|
||||
Status: ${scan.status}
|
||||
${scan.output}
|
||||
### ${s.tool.toUpperCase()}
|
||||
Status: ${s.status}
|
||||
${s.output}
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const nmapResult = nmap as StitchableResult | string | undefined;
|
||||
const subfinderResult = subfinder as StitchableResult | string | undefined;
|
||||
const whatwebResult = whatweb as StitchableResult | string | undefined;
|
||||
const naabuResult = naabu as StitchableResult | string | undefined;
|
||||
|
||||
const getStatus = (r: StitchableResult | string | undefined): string => {
|
||||
if (!r) return 'Skipped';
|
||||
if (typeof r === 'string') return 'Skipped';
|
||||
return r.status || 'Skipped';
|
||||
};
|
||||
|
||||
const getOutput = (r: StitchableResult | string | undefined): string => {
|
||||
if (!r) return 'No output';
|
||||
if (typeof r === 'string') return r;
|
||||
return r.output || 'No output';
|
||||
};
|
||||
|
||||
const report = `
|
||||
# Pre-Reconnaissance Report
|
||||
|
||||
## Port Discovery (naabu)
|
||||
Status: ${naabu?.status || 'Skipped'}
|
||||
${naabu?.output || naabu || 'No output'}
|
||||
Status: ${getStatus(naabuResult)}
|
||||
${getOutput(naabuResult)}
|
||||
|
||||
## Network Scanning (nmap)
|
||||
Status: ${nmap?.status || 'Skipped'}
|
||||
${nmap?.output || nmap || 'No output'}
|
||||
Status: ${getStatus(nmapResult)}
|
||||
${getOutput(nmapResult)}
|
||||
|
||||
## Subdomain Discovery (subfinder)
|
||||
Status: ${subfinder?.status || 'Skipped'}
|
||||
${subfinder?.output || subfinder || 'No output'}
|
||||
Status: ${getStatus(subfinderResult)}
|
||||
${getOutput(subfinderResult)}
|
||||
|
||||
## Technology Detection (whatweb)
|
||||
Status: ${whatweb?.status || 'Skipped'}
|
||||
${whatweb?.output || whatweb || 'No output'}
|
||||
Status: ${getStatus(whatwebResult)}
|
||||
${getOutput(whatwebResult)}
|
||||
## Code Analysis
|
||||
${codeAnalysisContent}
|
||||
${additionalSection}
|
||||
@@ -252,11 +337,12 @@ Report generated at: ${new Date().toISOString()}
|
||||
// Write to file in the cloned repository
|
||||
await fs.writeFile(deliverablePath, report);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
throw new PentestError(
|
||||
`Failed to write pre-recon report: ${error.message}`,
|
||||
`Failed to write pre-recon report: ${err.message}`,
|
||||
'filesystem',
|
||||
false,
|
||||
{ sourceDir, originalError: error.message }
|
||||
{ sourceDir, originalError: err.message }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,7 +350,15 @@ Report generated at: ${new Date().toISOString()}
|
||||
}
|
||||
|
||||
// Main pre-recon phase execution function
|
||||
export async function executePreReconPhase(webUrl, sourceDir, variables, config, toolAvailability, pipelineTestingMode, sessionId = null) {
|
||||
export async function executePreReconPhase(
|
||||
webUrl: string,
|
||||
sourceDir: string,
|
||||
variables: PromptVariables,
|
||||
config: DistributedConfig | null,
|
||||
toolAvailability: ToolAvailability,
|
||||
pipelineTestingMode: boolean,
|
||||
sessionId: string | null = null
|
||||
): Promise<PreReconResult> {
|
||||
console.log(chalk.yellow.bold('\n🔍 PHASE 1: PRE-RECONNAISSANCE'));
|
||||
const timer = new Timer('phase-1-pre-recon');
|
||||
|
||||
@@ -278,13 +372,13 @@ export async function executePreReconPhase(webUrl, sourceDir, variables, config,
|
||||
|
||||
console.log(chalk.blue('📝 Stitching pre-recon outputs...'));
|
||||
// Combine wave 1 and wave 2 results for stitching
|
||||
const allResults = [
|
||||
wave1Results.nmap,
|
||||
wave1Results.subfinder,
|
||||
wave1Results.whatweb,
|
||||
wave1Results.naabu,
|
||||
wave1Results.codeAnalysis,
|
||||
...(wave2Results.schemathesis ? [wave2Results.schemathesis] : [])
|
||||
const allResults: (StitchableResult | string | undefined)[] = [
|
||||
wave1Results.nmap as StitchableResult | string,
|
||||
wave1Results.subfinder as StitchableResult | string,
|
||||
wave1Results.whatweb as StitchableResult | string,
|
||||
wave1Results.naabu as StitchableResult | string | undefined,
|
||||
wave1Results.codeAnalysis as unknown as StitchableResult,
|
||||
...(wave2Results.schemathesis ? [wave2Results.schemathesis as StitchableResult] : [])
|
||||
];
|
||||
const preReconReport = await stitchPreReconOutputs(allResults, sourceDir);
|
||||
const duration = timer.stop();
|
||||
@@ -293,4 +387,4 @@ export async function executePreReconPhase(webUrl, sourceDir, variables, config,
|
||||
console.log(chalk.green(`💾 Saved to ${sourceDir}/deliverables/pre_recon_deliverable.md`));
|
||||
|
||||
return { duration, report: preReconReport };
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,15 @@ import { fs, path } from 'zx';
|
||||
import chalk from 'chalk';
|
||||
import { PentestError } from '../error-handling.js';
|
||||
|
||||
interface DeliverableFile {
|
||||
name: string;
|
||||
path: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
// Pure function: Assemble final report from specialist deliverables
|
||||
export async function assembleFinalReport(sourceDir) {
|
||||
const deliverableFiles = [
|
||||
export async function assembleFinalReport(sourceDir: string): Promise<string> {
|
||||
const deliverableFiles: DeliverableFile[] = [
|
||||
{ name: 'Injection', path: 'injection_exploitation_evidence.md', required: false },
|
||||
{ name: 'XSS', path: 'xss_exploitation_evidence.md', required: false },
|
||||
{ name: 'Authentication', path: 'auth_exploitation_evidence.md', required: false },
|
||||
@@ -18,7 +24,7 @@ export async function assembleFinalReport(sourceDir) {
|
||||
{ name: 'Authorization', path: 'authz_exploitation_evidence.md', required: false }
|
||||
];
|
||||
|
||||
const sections = [];
|
||||
const sections: string[] = [];
|
||||
|
||||
for (const file of deliverableFiles) {
|
||||
const filePath = path.join(sourceDir, 'deliverables', file.path);
|
||||
@@ -36,7 +42,8 @@ export async function assembleFinalReport(sourceDir) {
|
||||
if (file.required) {
|
||||
throw error;
|
||||
}
|
||||
console.log(chalk.yellow(`⚠️ Could not read ${file.path}: ${error.message}`));
|
||||
const err = error as Error;
|
||||
console.log(chalk.yellow(`⚠️ Could not read ${file.path}: ${err.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,13 +54,14 @@ export async function assembleFinalReport(sourceDir) {
|
||||
await fs.writeFile(finalReportPath, finalContent);
|
||||
console.log(chalk.green(`✅ Final report assembled at ${finalReportPath}`));
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
throw new PentestError(
|
||||
`Failed to write final report: ${error.message}`,
|
||||
`Failed to write final report: ${err.message}`,
|
||||
'filesystem',
|
||||
false,
|
||||
{ finalReportPath, originalError: error.message }
|
||||
{ finalReportPath, originalError: err.message }
|
||||
);
|
||||
}
|
||||
|
||||
return finalContent;
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,17 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
export class ProgressIndicator {
|
||||
constructor(message = 'Working...') {
|
||||
private message: string;
|
||||
private frames: string[] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
private frameIndex: number = 0;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private isRunning: boolean = false;
|
||||
|
||||
constructor(message: string = 'Working...') {
|
||||
this.message = message;
|
||||
this.frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
this.frameIndex = 0;
|
||||
this.interval = null;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
start(): void {
|
||||
if (this.isRunning) return;
|
||||
|
||||
this.isRunning = true;
|
||||
@@ -23,12 +25,14 @@ export class ProgressIndicator {
|
||||
|
||||
this.interval = setInterval(() => {
|
||||
// Clear the line and write the spinner
|
||||
process.stdout.write(`\r${chalk.cyan(this.frames[this.frameIndex])} ${chalk.dim(this.message)}`);
|
||||
process.stdout.write(
|
||||
`\r${chalk.cyan(this.frames[this.frameIndex])} ${chalk.dim(this.message)}`
|
||||
);
|
||||
this.frameIndex = (this.frameIndex + 1) % this.frames.length;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
stop() {
|
||||
stop(): void {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
if (this.interval) {
|
||||
@@ -41,8 +45,8 @@ export class ProgressIndicator {
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
finish(successMessage = 'Complete') {
|
||||
finish(successMessage: string = 'Complete'): void {
|
||||
this.stop();
|
||||
console.log(chalk.green(`✓ ${successMessage}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,21 @@ import { fs, path } from 'zx';
|
||||
import chalk from 'chalk';
|
||||
import { PentestError, handlePromptError } from '../error-handling.js';
|
||||
import { MCP_AGENT_MAPPING } from '../constants.js';
|
||||
import type { Authentication, DistributedConfig } from '../types/config.js';
|
||||
|
||||
interface PromptVariables {
|
||||
webUrl: string;
|
||||
repoPath: string;
|
||||
MCP_SERVER?: string;
|
||||
}
|
||||
|
||||
interface IncludeReplacement {
|
||||
placeholder: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Pure function: Build complete login instructions from config
|
||||
async function buildLoginInstructions(authentication) {
|
||||
async function buildLoginInstructions(authentication: Authentication): Promise<string> {
|
||||
try {
|
||||
// Load the login instructions template
|
||||
const loginInstructionsPath = path.join(import.meta.dirname, '..', '..', 'prompts', 'shared', 'login-instructions.txt');
|
||||
@@ -27,10 +39,10 @@ async function buildLoginInstructions(authentication) {
|
||||
const fullTemplate = await fs.readFile(loginInstructionsPath, 'utf8');
|
||||
|
||||
// Helper function to extract sections based on markers
|
||||
const getSection = (content, sectionName) => {
|
||||
const getSection = (content: string, sectionName: string): string => {
|
||||
const regex = new RegExp(`<!-- BEGIN:${sectionName} -->([\\s\\S]*?)<!-- END:${sectionName} -->`, 'g');
|
||||
const match = regex.exec(content);
|
||||
return match ? match[1].trim() : '';
|
||||
return match ? match[1]!.trim() : '';
|
||||
};
|
||||
|
||||
// Extract sections based on login type
|
||||
@@ -39,7 +51,7 @@ async function buildLoginInstructions(authentication) {
|
||||
|
||||
// Build instructions with only relevant sections
|
||||
const commonSection = getSection(fullTemplate, 'COMMON');
|
||||
const authSection = getSection(fullTemplate, loginType); // FORM or SSO
|
||||
const authSection = loginType ? getSection(fullTemplate, loginType) : ''; // FORM or SSO
|
||||
const verificationSection = getSection(fullTemplate, 'VERIFICATION');
|
||||
|
||||
// Fallback to full template if markers are missing (backward compatibility)
|
||||
@@ -54,7 +66,7 @@ async function buildLoginInstructions(authentication) {
|
||||
}
|
||||
|
||||
// Replace the user instructions placeholder with the login flow from config
|
||||
let userInstructions = authentication.login_flow.join('\n');
|
||||
let userInstructions = (authentication.login_flow ?? []).join('\n');
|
||||
|
||||
// Replace credential placeholders within the user instructions
|
||||
if (authentication.credentials) {
|
||||
@@ -81,22 +93,23 @@ async function buildLoginInstructions(authentication) {
|
||||
if (error instanceof PentestError) {
|
||||
throw error;
|
||||
}
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
throw new PentestError(
|
||||
`Failed to build login instructions: ${error.message}`,
|
||||
`Failed to build login instructions: ${errMsg}`,
|
||||
'config',
|
||||
false,
|
||||
{ authentication, originalError: error.message }
|
||||
{ authentication, originalError: errMsg }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pure function: Process @include() directives
|
||||
async function processIncludes(content, baseDir) {
|
||||
async function processIncludes(content: string, baseDir: string): Promise<string> {
|
||||
const includeRegex = /@include\(([^)]+)\)/g;
|
||||
// Use a Promise.all to handle all includes concurrently
|
||||
const replacements = await Promise.all(
|
||||
const replacements: IncludeReplacement[] = await Promise.all(
|
||||
Array.from(content.matchAll(includeRegex)).map(async (match) => {
|
||||
const includePath = path.join(baseDir, match[1]);
|
||||
const includePath = path.join(baseDir, match[1]!);
|
||||
const sharedContent = await fs.readFile(includePath, 'utf8');
|
||||
return {
|
||||
placeholder: match[0],
|
||||
@@ -112,7 +125,11 @@ async function processIncludes(content, baseDir) {
|
||||
}
|
||||
|
||||
// Pure function: Variable interpolation
|
||||
async function interpolateVariables(template, variables, config = null) {
|
||||
async function interpolateVariables(
|
||||
template: string,
|
||||
variables: PromptVariables,
|
||||
config: DistributedConfig | null = null
|
||||
): Promise<string> {
|
||||
try {
|
||||
if (!template || typeof template !== 'string') {
|
||||
throw new PentestError(
|
||||
@@ -147,8 +164,8 @@ async function interpolateVariables(template, variables, config = null) {
|
||||
const cleanRulesSection = '<rules>\nNo specific rules or focus areas provided for this test.\n</rules>';
|
||||
result = result.replace(/<rules>[\s\S]*?<\/rules>/g, cleanRulesSection);
|
||||
} else {
|
||||
const avoidRules = hasAvoidRules ? config.avoid.map(r => `- ${r.description}`).join('\n') : 'None';
|
||||
const focusRules = hasFocusRules ? config.focus.map(r => `- ${r.description}`).join('\n') : 'None';
|
||||
const avoidRules = hasAvoidRules ? config.avoid!.map(r => `- ${r.description}`).join('\n') : 'None';
|
||||
const focusRules = hasFocusRules ? config.focus!.map(r => `- ${r.description}`).join('\n') : 'None';
|
||||
|
||||
result = result
|
||||
.replace(/{{RULES_AVOID}}/g, avoidRules)
|
||||
@@ -180,17 +197,23 @@ async function interpolateVariables(template, variables, config = null) {
|
||||
if (error instanceof PentestError) {
|
||||
throw error;
|
||||
}
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
throw new PentestError(
|
||||
`Variable interpolation failed: ${error.message}`,
|
||||
`Variable interpolation failed: ${errMsg}`,
|
||||
'prompt',
|
||||
false,
|
||||
{ originalError: error.message }
|
||||
{ originalError: errMsg }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pure function: Load and interpolate prompt template
|
||||
export async function loadPrompt(promptName, variables, config = null, pipelineTestingMode = false) {
|
||||
export async function loadPrompt(
|
||||
promptName: string,
|
||||
variables: PromptVariables,
|
||||
config: DistributedConfig | null = null,
|
||||
pipelineTestingMode: boolean = false
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Use pipeline testing prompts if pipeline testing mode is enabled
|
||||
const baseDir = pipelineTestingMode ? 'prompts/pipeline-testing' : 'prompts';
|
||||
@@ -213,11 +236,12 @@ export async function loadPrompt(promptName, variables, config = null, pipelineT
|
||||
}
|
||||
|
||||
// Add MCP server assignment to variables
|
||||
const enhancedVariables = { ...variables };
|
||||
const enhancedVariables: PromptVariables = { ...variables };
|
||||
|
||||
// Assign MCP server based on prompt name (agent name)
|
||||
if (MCP_AGENT_MAPPING[promptName]) {
|
||||
enhancedVariables.MCP_SERVER = MCP_AGENT_MAPPING[promptName];
|
||||
const mcpServer = MCP_AGENT_MAPPING[promptName as keyof typeof MCP_AGENT_MAPPING];
|
||||
if (mcpServer) {
|
||||
enhancedVariables.MCP_SERVER = mcpServer;
|
||||
console.log(chalk.gray(` 🎭 Assigned ${promptName} → ${enhancedVariables.MCP_SERVER}`));
|
||||
} else {
|
||||
// Fallback for unknown agents
|
||||
@@ -235,7 +259,7 @@ export async function loadPrompt(promptName, variables, config = null, pipelineT
|
||||
if (error instanceof PentestError) {
|
||||
throw error;
|
||||
}
|
||||
const promptError = handlePromptError(promptName, error);
|
||||
const promptError = handlePromptError(promptName, error as Error);
|
||||
throw promptError.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,39 +7,110 @@
|
||||
import { fs, path } from 'zx';
|
||||
import { PentestError } from './error-handling.js';
|
||||
|
||||
export type VulnType = 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz';
|
||||
|
||||
interface VulnTypeConfigItem {
|
||||
deliverable: string;
|
||||
queue: string;
|
||||
}
|
||||
|
||||
type VulnTypeConfig = Record<VulnType, VulnTypeConfigItem>;
|
||||
|
||||
interface ValidationRule {
|
||||
predicate: (existence: FileExistence) => boolean;
|
||||
errorMessage: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
interface FileExistence {
|
||||
deliverableExists: boolean;
|
||||
queueExists: boolean;
|
||||
}
|
||||
|
||||
interface PathsBase {
|
||||
vulnType: VulnType;
|
||||
deliverable: string;
|
||||
queue: string;
|
||||
sourceDir: string;
|
||||
}
|
||||
|
||||
interface PathsWithExistence extends PathsBase {
|
||||
existence: FileExistence;
|
||||
}
|
||||
|
||||
interface PathsWithQueue extends PathsWithExistence {
|
||||
queueData: QueueData;
|
||||
}
|
||||
|
||||
interface PathsWithError {
|
||||
error: PentestError;
|
||||
}
|
||||
|
||||
interface QueueData {
|
||||
vulnerabilities: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface QueueValidationResult {
|
||||
valid: boolean;
|
||||
data: QueueData | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ExploitationDecision {
|
||||
shouldExploit: boolean;
|
||||
shouldRetry: boolean;
|
||||
vulnerabilityCount: number;
|
||||
vulnType: VulnType;
|
||||
}
|
||||
|
||||
export interface SafeValidationResult {
|
||||
success: boolean;
|
||||
data?: ExploitationDecision;
|
||||
error?: PentestError;
|
||||
}
|
||||
|
||||
// Vulnerability type configuration as immutable data
|
||||
const VULN_TYPE_CONFIG = Object.freeze({
|
||||
injection: Object.freeze({
|
||||
deliverable: 'injection_analysis_deliverable.md',
|
||||
queue: 'injection_exploitation_queue.json'
|
||||
const VULN_TYPE_CONFIG: VulnTypeConfig = Object.freeze({
|
||||
injection: Object.freeze({
|
||||
deliverable: 'injection_analysis_deliverable.md',
|
||||
queue: 'injection_exploitation_queue.json',
|
||||
}),
|
||||
xss: Object.freeze({
|
||||
deliverable: 'xss_analysis_deliverable.md',
|
||||
queue: 'xss_exploitation_queue.json'
|
||||
xss: Object.freeze({
|
||||
deliverable: 'xss_analysis_deliverable.md',
|
||||
queue: 'xss_exploitation_queue.json',
|
||||
}),
|
||||
auth: Object.freeze({
|
||||
deliverable: 'auth_analysis_deliverable.md',
|
||||
queue: 'auth_exploitation_queue.json'
|
||||
auth: Object.freeze({
|
||||
deliverable: 'auth_analysis_deliverable.md',
|
||||
queue: 'auth_exploitation_queue.json',
|
||||
}),
|
||||
ssrf: Object.freeze({
|
||||
deliverable: 'ssrf_analysis_deliverable.md',
|
||||
queue: 'ssrf_exploitation_queue.json'
|
||||
ssrf: Object.freeze({
|
||||
deliverable: 'ssrf_analysis_deliverable.md',
|
||||
queue: 'ssrf_exploitation_queue.json',
|
||||
}),
|
||||
authz: Object.freeze({
|
||||
deliverable: 'authz_analysis_deliverable.md',
|
||||
queue: 'authz_exploitation_queue.json'
|
||||
})
|
||||
});
|
||||
authz: Object.freeze({
|
||||
deliverable: 'authz_analysis_deliverable.md',
|
||||
queue: 'authz_exploitation_queue.json',
|
||||
}),
|
||||
}) as VulnTypeConfig;
|
||||
|
||||
// Functional composition utilities - async pipe for promise chain
|
||||
const pipe = (...fns) => x => fns.reduce(async (v, f) => f(await v), x);
|
||||
type PipeFunction = (x: any) => any | Promise<any>;
|
||||
|
||||
const pipe =
|
||||
(...fns: PipeFunction[]) =>
|
||||
(x: any): Promise<any> =>
|
||||
fns.reduce(async (v, f) => f(await v), Promise.resolve(x));
|
||||
|
||||
// Pure function to create validation rule
|
||||
const createValidationRule = (predicate, errorMessage, retryable = true) =>
|
||||
Object.freeze({ predicate, errorMessage, retryable });
|
||||
const createValidationRule = (
|
||||
predicate: (existence: FileExistence) => boolean,
|
||||
errorMessage: string,
|
||||
retryable: boolean = true
|
||||
): ValidationRule => Object.freeze({ predicate, errorMessage, retryable });
|
||||
|
||||
// Validation rules for file existence (following QUEUE_VALIDATION_FLOW.md)
|
||||
const fileExistenceRules = Object.freeze([
|
||||
const fileExistenceRules: readonly ValidationRule[] = Object.freeze([
|
||||
// Rule 1: Neither deliverable nor queue exists
|
||||
createValidationRule(
|
||||
({ deliverableExists, queueExists }) => deliverableExists || queueExists,
|
||||
@@ -54,176 +125,196 @@ const fileExistenceRules = Object.freeze([
|
||||
createValidationRule(
|
||||
({ deliverableExists, queueExists }) => !(queueExists && !deliverableExists),
|
||||
'Analysis incomplete: Queue exists but deliverable file missing. Analysis agent must create both files.'
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
// Pure function to create file paths
|
||||
const createPaths = (vulnType, sourceDir) => {
|
||||
const createPaths = (
|
||||
vulnType: VulnType,
|
||||
sourceDir: string
|
||||
): PathsBase | PathsWithError => {
|
||||
const config = VULN_TYPE_CONFIG[vulnType];
|
||||
if (!config) {
|
||||
return {
|
||||
return {
|
||||
error: new PentestError(
|
||||
`Unknown vulnerability type: ${vulnType}`,
|
||||
'validation',
|
||||
false,
|
||||
{ vulnType }
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return Object.freeze({
|
||||
vulnType,
|
||||
deliverable: path.join(sourceDir, 'deliverables', config.deliverable),
|
||||
queue: path.join(sourceDir, 'deliverables', config.queue),
|
||||
sourceDir
|
||||
sourceDir,
|
||||
});
|
||||
};
|
||||
|
||||
// Pure function to check file existence
|
||||
const checkFileExistence = async (paths) => {
|
||||
if (paths.error) return paths;
|
||||
|
||||
const checkFileExistence = async (
|
||||
paths: PathsBase | PathsWithError
|
||||
): Promise<PathsWithExistence | PathsWithError> => {
|
||||
if ('error' in paths) return paths;
|
||||
|
||||
const [deliverableExists, queueExists] = await Promise.all([
|
||||
fs.pathExists(paths.deliverable),
|
||||
fs.pathExists(paths.queue)
|
||||
fs.pathExists(paths.queue),
|
||||
]);
|
||||
|
||||
|
||||
return Object.freeze({
|
||||
...paths,
|
||||
existence: Object.freeze({ deliverableExists, queueExists })
|
||||
existence: Object.freeze({ deliverableExists, queueExists }),
|
||||
});
|
||||
};
|
||||
|
||||
// Pure function to validate existence rules
|
||||
const validateExistenceRules = (pathsWithExistence) => {
|
||||
if (pathsWithExistence.error) return pathsWithExistence;
|
||||
|
||||
const validateExistenceRules = (
|
||||
pathsWithExistence: PathsWithExistence | PathsWithError
|
||||
): PathsWithExistence | PathsWithError => {
|
||||
if ('error' in pathsWithExistence) return pathsWithExistence;
|
||||
|
||||
const { existence, vulnType } = pathsWithExistence;
|
||||
|
||||
|
||||
// Find the first rule that fails
|
||||
const failedRule = fileExistenceRules.find(rule => !rule.predicate(existence));
|
||||
|
||||
const failedRule = fileExistenceRules.find((rule) => !rule.predicate(existence));
|
||||
|
||||
if (failedRule) {
|
||||
return {
|
||||
...pathsWithExistence,
|
||||
error: new PentestError(
|
||||
`${failedRule.errorMessage} (${vulnType})`,
|
||||
'validation',
|
||||
failedRule.retryable,
|
||||
{
|
||||
vulnType,
|
||||
{
|
||||
vulnType,
|
||||
deliverablePath: pathsWithExistence.deliverable,
|
||||
queuePath: pathsWithExistence.queue,
|
||||
existence
|
||||
existence,
|
||||
}
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return pathsWithExistence;
|
||||
};
|
||||
|
||||
// Pure function to validate queue structure
|
||||
const validateQueueStructure = (content) => {
|
||||
const validateQueueStructure = (content: string): QueueValidationResult => {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
const isValid =
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'vulnerabilities' in parsed &&
|
||||
Array.isArray((parsed as QueueData).vulnerabilities);
|
||||
|
||||
return Object.freeze({
|
||||
valid: parsed.vulnerabilities && Array.isArray(parsed.vulnerabilities),
|
||||
data: parsed,
|
||||
error: null
|
||||
valid: isValid,
|
||||
data: isValid ? (parsed as QueueData) : null,
|
||||
error: null,
|
||||
});
|
||||
} catch (parseError) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
data: null,
|
||||
error: parseError.message
|
||||
error: parseError instanceof Error ? parseError.message : String(parseError),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Pure function to read and validate queue content
|
||||
const validateQueueContent = async (pathsWithExistence) => {
|
||||
if (pathsWithExistence.error) return pathsWithExistence;
|
||||
|
||||
const validateQueueContent = async (
|
||||
pathsWithExistence: PathsWithExistence | PathsWithError
|
||||
): Promise<PathsWithQueue | PathsWithError> => {
|
||||
if ('error' in pathsWithExistence) return pathsWithExistence;
|
||||
|
||||
try {
|
||||
const queueContent = await fs.readFile(pathsWithExistence.queue, 'utf8');
|
||||
const queueValidation = validateQueueStructure(queueContent);
|
||||
|
||||
|
||||
if (!queueValidation.valid) {
|
||||
// Rule 6: Both exist, queue invalid
|
||||
return {
|
||||
...pathsWithExistence,
|
||||
error: new PentestError(
|
||||
queueValidation.error
|
||||
queueValidation.error
|
||||
? `Queue validation failed for ${pathsWithExistence.vulnType}: Invalid JSON structure. Analysis agent must fix queue format.`
|
||||
: `Queue validation failed for ${pathsWithExistence.vulnType}: Missing or invalid 'vulnerabilities' array. Analysis agent must fix queue structure.`,
|
||||
'validation',
|
||||
true, // retryable
|
||||
{
|
||||
{
|
||||
vulnType: pathsWithExistence.vulnType,
|
||||
queuePath: pathsWithExistence.queue,
|
||||
originalError: queueValidation.error,
|
||||
queueStructure: queueValidation.data ? Object.keys(queueValidation.data) : []
|
||||
queueStructure: queueValidation.data ? Object.keys(queueValidation.data) : [],
|
||||
}
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return Object.freeze({
|
||||
...pathsWithExistence,
|
||||
queueData: queueValidation.data
|
||||
queueData: queueValidation.data!,
|
||||
});
|
||||
} catch (readError) {
|
||||
return {
|
||||
...pathsWithExistence,
|
||||
error: new PentestError(
|
||||
`Failed to read queue file for ${pathsWithExistence.vulnType}: ${readError.message}`,
|
||||
`Failed to read queue file for ${pathsWithExistence.vulnType}: ${readError instanceof Error ? readError.message : String(readError)}`,
|
||||
'filesystem',
|
||||
false,
|
||||
{
|
||||
{
|
||||
vulnType: pathsWithExistence.vulnType,
|
||||
queuePath: pathsWithExistence.queue,
|
||||
originalError: readError.message
|
||||
originalError: readError instanceof Error ? readError.message : String(readError),
|
||||
}
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Pure function to determine exploitation decision
|
||||
const determineExploitationDecision = (validatedData) => {
|
||||
if (validatedData.error) {
|
||||
const determineExploitationDecision = (
|
||||
validatedData: PathsWithQueue | PathsWithError
|
||||
): ExploitationDecision => {
|
||||
if ('error' in validatedData) {
|
||||
throw validatedData.error;
|
||||
}
|
||||
|
||||
|
||||
const hasVulnerabilities = validatedData.queueData.vulnerabilities.length > 0;
|
||||
|
||||
|
||||
// Rule 4: Both exist, queue valid and populated
|
||||
// Rule 5: Both exist, queue valid but empty
|
||||
return Object.freeze({
|
||||
shouldExploit: hasVulnerabilities,
|
||||
shouldRetry: false,
|
||||
vulnerabilityCount: validatedData.queueData.vulnerabilities.length,
|
||||
vulnType: validatedData.vulnType
|
||||
vulnType: validatedData.vulnType,
|
||||
});
|
||||
};
|
||||
|
||||
// Main functional validation pipeline
|
||||
export const validateQueueAndDeliverable = async (vulnType, sourceDir) =>
|
||||
await pipe(
|
||||
export const validateQueueAndDeliverable = async (
|
||||
vulnType: VulnType,
|
||||
sourceDir: string
|
||||
): Promise<ExploitationDecision> =>
|
||||
(await pipe(
|
||||
() => createPaths(vulnType, sourceDir),
|
||||
checkFileExistence,
|
||||
validateExistenceRules,
|
||||
validateQueueContent,
|
||||
determineExploitationDecision
|
||||
)();
|
||||
)(() => createPaths(vulnType, sourceDir))) as ExploitationDecision;
|
||||
|
||||
// Pure function to safely validate (returns result instead of throwing)
|
||||
export const safeValidateQueueAndDeliverable = async (vulnType, sourceDir) => {
|
||||
export const safeValidateQueueAndDeliverable = async (
|
||||
vulnType: VulnType,
|
||||
sourceDir: string
|
||||
): Promise<SafeValidationResult> => {
|
||||
try {
|
||||
const result = await validateQueueAndDeliverable(vulnType, sourceDir);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
return { success: false, error: error as PentestError };
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -10,10 +10,69 @@ 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>;
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
status: 'in-progress' | 'completed' | 'failed';
|
||||
completedAgents: AgentName[];
|
||||
failedAgents: AgentName[];
|
||||
checkpoints: Record<AgentName, string>;
|
||||
createdAt: string;
|
||||
lastActivity: string;
|
||||
}
|
||||
|
||||
// Session store interface
|
||||
interface SessionStore {
|
||||
sessions: Record<string, Session>;
|
||||
}
|
||||
|
||||
// Session status result
|
||||
export interface SessionStatusResult {
|
||||
status: 'in-progress' | 'completed' | 'failed';
|
||||
completedCount: number;
|
||||
totalAgents: number;
|
||||
failedCount: number;
|
||||
completionPercentage: number;
|
||||
}
|
||||
|
||||
// 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)
|
||||
export const generateSessionLogPath = (webUrl, sessionId) => {
|
||||
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);
|
||||
@@ -22,7 +81,7 @@ export const generateSessionLogPath = (webUrl, sessionId) => {
|
||||
const sessionMutex = new SessionMutex();
|
||||
|
||||
// Agent definitions according to PRD
|
||||
export const AGENTS = Object.freeze({
|
||||
export const AGENTS: Readonly<Record<AgentName, AgentDefinition>> = Object.freeze({
|
||||
// Phase 1 - Pre-reconnaissance
|
||||
'pre-recon': {
|
||||
name: 'pre-recon',
|
||||
@@ -31,8 +90,8 @@ export const AGENTS = Object.freeze({
|
||||
order: 1,
|
||||
prerequisites: []
|
||||
},
|
||||
|
||||
// Phase 2 - Reconnaissance
|
||||
|
||||
// Phase 2 - Reconnaissance
|
||||
'recon': {
|
||||
name: 'recon',
|
||||
displayName: 'Recon agent',
|
||||
@@ -40,7 +99,7 @@ export const AGENTS = Object.freeze({
|
||||
order: 2,
|
||||
prerequisites: ['pre-recon']
|
||||
},
|
||||
|
||||
|
||||
// Phase 3 - Vulnerability Analysis
|
||||
'injection-vuln': {
|
||||
name: 'injection-vuln',
|
||||
@@ -77,7 +136,7 @@ export const AGENTS = Object.freeze({
|
||||
order: 7,
|
||||
prerequisites: ['recon']
|
||||
},
|
||||
|
||||
|
||||
// Phase 4 - Exploitation
|
||||
'injection-exploit': {
|
||||
name: 'injection-exploit',
|
||||
@@ -114,7 +173,7 @@ export const AGENTS = Object.freeze({
|
||||
order: 12,
|
||||
prerequisites: ['authz-vuln']
|
||||
},
|
||||
|
||||
|
||||
// Phase 5 - Reporting
|
||||
'report': {
|
||||
name: 'report',
|
||||
@@ -126,7 +185,7 @@ export const AGENTS = Object.freeze({
|
||||
});
|
||||
|
||||
// Phase definitions
|
||||
export const PHASES = Object.freeze({
|
||||
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'],
|
||||
@@ -138,46 +197,48 @@ export const PHASES = Object.freeze({
|
||||
const STORE_FILE = path.join(process.cwd(), '.shannon-store.json');
|
||||
|
||||
// Load sessions from store file
|
||||
const loadSessions = async () => {
|
||||
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);
|
||||
|
||||
const store = JSON.parse(content) as unknown;
|
||||
|
||||
// Validate store structure
|
||||
if (!store || typeof store !== 'object' || !store.sessions) {
|
||||
if (!store || typeof store !== 'object' || !('sessions' in store)) {
|
||||
console.log(chalk.yellow('⚠️ Invalid session store format, creating new store'));
|
||||
return { sessions: {} };
|
||||
}
|
||||
|
||||
return store;
|
||||
|
||||
return store as SessionStore;
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(`⚠️ Failed to load session store: ${error.message}, creating new store`));
|
||||
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) => {
|
||||
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: ${error.message}`,
|
||||
`Failed to save session store: ${errMsg}`,
|
||||
'filesystem',
|
||||
false,
|
||||
{ storeFile: STORE_FILE, originalError: error.message }
|
||||
{ storeFile: STORE_FILE, originalError: errMsg }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Find existing session for the same web URL and repository path
|
||||
const findExistingSession = async (webUrl, targetRepo) => {
|
||||
const findExistingSession = async (webUrl: string, targetRepo: string): Promise<Session | undefined> => {
|
||||
const store = await loadSessions();
|
||||
const sessions = Object.values(store.sessions);
|
||||
|
||||
@@ -194,13 +255,18 @@ const findExistingSession = async (webUrl, targetRepo) => {
|
||||
};
|
||||
|
||||
// Generate session ID as unique UUID
|
||||
const generateSessionId = () => {
|
||||
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, repoPath, configFile = null, targetRepo = null) => {
|
||||
export const createSession = async (
|
||||
webUrl: string,
|
||||
repoPath: string,
|
||||
configFile: string | null = null,
|
||||
targetRepo: string | null = null
|
||||
): Promise<Session> => {
|
||||
// Use targetRepo if provided, otherwise use repoPath
|
||||
const resolvedTargetRepo = targetRepo || repoPath;
|
||||
|
||||
@@ -226,7 +292,7 @@ export const createSession = async (webUrl, repoPath, configFile = null, targetR
|
||||
|
||||
// STANDARD: All sessions use 'id' field (NOT 'sessionId')
|
||||
// This is the canonical session structure used throughout the codebase
|
||||
const session = {
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
webUrl,
|
||||
repoPath,
|
||||
@@ -235,7 +301,7 @@ export const createSession = async (webUrl, repoPath, configFile = null, targetR
|
||||
status: 'in-progress',
|
||||
completedAgents: [],
|
||||
failedAgents: [],
|
||||
checkpoints: {},
|
||||
checkpoints: {} as Record<AgentName, string>,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivity: new Date().toISOString()
|
||||
};
|
||||
@@ -248,15 +314,18 @@ export const createSession = async (webUrl, repoPath, configFile = null, targetR
|
||||
};
|
||||
|
||||
// Get session by ID
|
||||
export const getSession = async (sessionId) => {
|
||||
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, updates) => {
|
||||
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`,
|
||||
@@ -265,27 +334,27 @@ export const updateSession = async (sessionId, updates) => {
|
||||
{ sessionId }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
store.sessions[sessionId] = {
|
||||
...store.sessions[sessionId],
|
||||
...store.sessions[sessionId]!,
|
||||
...updates,
|
||||
lastActivity: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
await saveSessions(store);
|
||||
return store.sessions[sessionId];
|
||||
return store.sessions[sessionId]!;
|
||||
};
|
||||
|
||||
// List all sessions
|
||||
const listSessions = async () => {
|
||||
const listSessions = async (): Promise<Session[]> => {
|
||||
const store = await loadSessions();
|
||||
return Object.values(store.sessions);
|
||||
};
|
||||
|
||||
// Interactive session selection
|
||||
export const selectSession = async () => {
|
||||
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.',
|
||||
@@ -293,14 +362,14 @@ export const selectSession = async () => {
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (sessions.length === 1) {
|
||||
return sessions[0];
|
||||
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;
|
||||
@@ -309,7 +378,6 @@ export const selectSession = async () => {
|
||||
// Use dynamic status calculation instead of stored status
|
||||
const { status } = getSessionStatus(session);
|
||||
const statusColor = status === 'completed' ? chalk.green : chalk.blue;
|
||||
const statusIcon = status === 'completed' ? '✅' : '🔄';
|
||||
|
||||
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`));
|
||||
@@ -321,7 +389,7 @@ export const selectSession = async () => {
|
||||
|
||||
console.log(); // Empty line between sessions
|
||||
});
|
||||
|
||||
|
||||
// Get user selection
|
||||
return await promptSelection(
|
||||
chalk.cyan(`Select session (1-${sessions.length}):`),
|
||||
@@ -330,8 +398,9 @@ export const selectSession = async () => {
|
||||
};
|
||||
|
||||
// Validate agent name
|
||||
export const validateAgent = (agentName) => {
|
||||
if (!AGENTS[agentName]) {
|
||||
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',
|
||||
@@ -339,14 +408,14 @@ export const validateAgent = (agentName) => {
|
||||
{ agentName, validAgents: Object.keys(AGENTS) }
|
||||
);
|
||||
}
|
||||
return AGENTS[agentName];
|
||||
return agent;
|
||||
};
|
||||
|
||||
// Validate agent range
|
||||
export const validateAgentRange = (startAgent, endAgent) => {
|
||||
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.`,
|
||||
@@ -355,18 +424,19 @@ export const validateAgentRange = (startAgent, endAgent) => {
|
||||
{ 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) => {
|
||||
if (!PHASES[phaseName]) {
|
||||
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',
|
||||
@@ -374,17 +444,17 @@ export const validatePhase = (phaseName) => {
|
||||
{ phaseName, validPhases: Object.keys(PHASES) }
|
||||
);
|
||||
}
|
||||
return PHASES[phaseName].map(agentName => AGENTS[agentName]);
|
||||
return phase.map(agentName => AGENTS[agentName]!);
|
||||
};
|
||||
|
||||
// Check prerequisites for an agent
|
||||
export const checkPrerequisites = (session, agentName) => {
|
||||
export const checkPrerequisites = (session: Session, agentName: string): boolean => {
|
||||
const agent = validateAgent(agentName);
|
||||
|
||||
const missingPrereqs = agent.prerequisites.filter(prereq =>
|
||||
|
||||
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(', ')}`,
|
||||
@@ -393,33 +463,36 @@ export const checkPrerequisites = (session, agentName) => {
|
||||
{ agentName, missingPrerequisites: missingPrereqs, completedAgents: session.completedAgents }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Get next suggested agent
|
||||
export const getNextAgent = (session) => {
|
||||
export const getNextAgent = (session: Session): AgentDefinition | undefined => {
|
||||
const completed = new Set(session.completedAgents);
|
||||
const failed = new Set(session.failedAgents);
|
||||
|
||||
|
||||
// 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, agentName, checkpointCommit) => {
|
||||
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);
|
||||
|
||||
@@ -432,18 +505,18 @@ export const markAgentCompleted = async (sessionId, agentName, checkpointCommit)
|
||||
|
||||
validateAgent(agentName);
|
||||
|
||||
const updates = {
|
||||
completedAgents: [...new Set([...session.completedAgents, 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) {
|
||||
if (updates.completedAgents!.length === totalAgents) {
|
||||
updates.status = 'completed';
|
||||
}
|
||||
|
||||
@@ -455,32 +528,32 @@ export const markAgentCompleted = async (sessionId, agentName, checkpointCommit)
|
||||
};
|
||||
|
||||
// Mark agent as failed
|
||||
export const markAgentFailed = async (sessionId, agentName) => {
|
||||
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 = {
|
||||
failedAgents: [...new Set([...session.failedAgents, 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) => {
|
||||
const getTimeAgo = (timestamp: string): string => {
|
||||
const now = new Date();
|
||||
const past = new Date(timestamp);
|
||||
const diffMs = now - past;
|
||||
|
||||
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) {
|
||||
@@ -491,12 +564,12 @@ const getTimeAgo = (timestamp) => {
|
||||
};
|
||||
|
||||
// Get session status summary
|
||||
export const getSessionStatus = (session) => {
|
||||
export const getSessionStatus = (session: Session): SessionStatusResult => {
|
||||
const totalAgents = Object.keys(AGENTS).length;
|
||||
const completedCount = session.completedAgents.length;
|
||||
const failedCount = session.failedAgents.length;
|
||||
|
||||
let status;
|
||||
let status: 'in-progress' | 'completed' | 'failed';
|
||||
if (completedCount === totalAgents) {
|
||||
status = 'completed';
|
||||
} else if (failedCount > 0) {
|
||||
@@ -515,41 +588,62 @@ export const getSessionStatus = (session) => {
|
||||
};
|
||||
|
||||
// Calculate comprehensive summary statistics for vulnerability analysis
|
||||
export const calculateVulnerabilityAnalysisSummary = (session) => {
|
||||
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));
|
||||
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) => {
|
||||
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));
|
||||
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, targetAgent) => {
|
||||
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]) {
|
||||
|
||||
if (!session.checkpoints[targetAgent as AgentName]) {
|
||||
throw new PentestError(
|
||||
`No checkpoint found for agent '${targetAgent}' in session history`,
|
||||
'validation',
|
||||
@@ -557,19 +651,19 @@ export const rollbackToAgent = async (sessionId, targetAgent) => {
|
||||
{ targetAgent, availableCheckpoints: Object.keys(session.checkpoints) }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Find agents that need to be removed (those after the target agent)
|
||||
const targetOrder = AGENTS[targetAgent].order;
|
||||
const targetOrder = AGENTS[targetAgent as AgentName]!.order;
|
||||
const agentsToRemove = Object.values(AGENTS)
|
||||
.filter(agent => agent.order > targetOrder)
|
||||
.map(agent => agent.name);
|
||||
|
||||
const updates = {
|
||||
|
||||
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))
|
||||
)
|
||||
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
|
||||
@@ -590,11 +684,8 @@ export const rollbackToAgent = async (sessionId, targetAgent) => {
|
||||
* 3. VERIFICATION: Ensure audit state fully reflected in orchestration
|
||||
*
|
||||
* Critical for crash recovery, especially crash during rollback operations.
|
||||
*
|
||||
* @param {string} sessionId - Session ID to reconcile
|
||||
* @returns {Promise<Object>} Reconciliation report with added/removed/failed agents
|
||||
*/
|
||||
export const reconcileSession = async (sessionId) => {
|
||||
export const reconcileSession = async (sessionId: string): Promise<ReconciliationReport> => {
|
||||
const { AuditSession } = await import('./audit/index.js');
|
||||
|
||||
// Get Shannon store session
|
||||
@@ -603,12 +694,17 @@ export const reconcileSession = async (sessionId) => {
|
||||
throw new PentestError(`Session ${sessionId} not found in Shannon store`, 'validation', false);
|
||||
}
|
||||
|
||||
// Get audit session data
|
||||
const auditSession = new AuditSession(shannonSession);
|
||||
// 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();
|
||||
const auditData = await auditSession.getMetrics() as AuditMetricsData;
|
||||
|
||||
const report = {
|
||||
const report: ReconciliationReport = {
|
||||
promotions: [],
|
||||
demotions: [],
|
||||
failures: []
|
||||
@@ -617,14 +713,14 @@ export const reconcileSession = async (sessionId) => {
|
||||
// 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')
|
||||
.filter(([, agentData]) => agentData.status === 'success')
|
||||
.map(([agentName]) => agentName);
|
||||
|
||||
const missing = auditCompleted.filter(agent => !shannonSession.completedAgents.includes(agent));
|
||||
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 || null;
|
||||
const checkpoint = agentData?.checkpoint || '';
|
||||
await markAgentCompleted(sessionId, agentName, checkpoint);
|
||||
report.promotions.push(agentName);
|
||||
}
|
||||
@@ -632,7 +728,7 @@ export const reconcileSession = async (sessionId) => {
|
||||
// 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')
|
||||
.filter(([, agentData]) => agentData.status === 'rolled-back')
|
||||
.map(([agentName]) => agentName);
|
||||
|
||||
const toRemove = shannonSession.completedAgents.filter(agent => auditRolledBack.includes(agent));
|
||||
@@ -641,24 +737,26 @@ export const reconcileSession = async (sessionId) => {
|
||||
// Reload session to get fresh state
|
||||
const freshSession = await getSession(sessionId);
|
||||
|
||||
const updates = {
|
||||
completedAgents: freshSession.completedAgents.filter(agent => !toRemove.includes(agent)),
|
||||
checkpoints: Object.fromEntries(
|
||||
Object.entries(freshSession.checkpoints).filter(([agent]) => !toRemove.includes(agent))
|
||||
)
|
||||
};
|
||||
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);
|
||||
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')
|
||||
.filter(([, agentData]) => agentData.status === 'failed')
|
||||
.map(([agentName]) => agentName);
|
||||
|
||||
const failedToAdd = auditFailed.filter(agent => !shannonSession.failedAgents.includes(agent));
|
||||
const failedToAdd = auditFailed.filter(agent => !shannonSession.failedAgents.includes(agent as AgentName));
|
||||
|
||||
for (const agentName of failedToAdd) {
|
||||
await markAgentFailed(sessionId, agentName);
|
||||
@@ -669,9 +767,9 @@ export const reconcileSession = async (sessionId) => {
|
||||
};
|
||||
|
||||
// Delete a specific session by ID
|
||||
export const deleteSession = async (sessionId) => {
|
||||
export const deleteSession = async (sessionId: string): Promise<Session> => {
|
||||
const store = await loadSessions();
|
||||
|
||||
|
||||
if (!store.sessions[sessionId]) {
|
||||
throw new PentestError(
|
||||
`Session ${sessionId} not found`,
|
||||
@@ -680,16 +778,16 @@ export const deleteSession = async (sessionId) => {
|
||||
{ sessionId }
|
||||
);
|
||||
}
|
||||
|
||||
const deletedSession = store.sessions[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 () => {
|
||||
export const deleteAllSessions = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (await fs.pathExists(STORE_FILE)) {
|
||||
await fs.remove(STORE_FILE);
|
||||
@@ -697,11 +795,12 @@ export const deleteAllSessions = async () => {
|
||||
}
|
||||
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: ${error.message}`,
|
||||
`Failed to delete session storage: ${errMsg}`,
|
||||
'filesystem',
|
||||
false,
|
||||
{ storeFile: STORE_FILE, originalError: error.message }
|
||||
{ storeFile: STORE_FILE, originalError: errMsg }
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import chalk from 'chalk';
|
||||
import { PentestError } from '../error-handling.js';
|
||||
|
||||
// Pure function: Setup local repository for testing
|
||||
export async function setupLocalRepo(repoPath) {
|
||||
export async function setupLocalRepo(repoPath: string): Promise<string> {
|
||||
try {
|
||||
const sourceDir = path.resolve(repoPath);
|
||||
|
||||
@@ -34,7 +34,8 @@ export async function setupLocalRepo(repoPath) {
|
||||
await $`cd ${sourceDir} && git add -A && git commit -m "Initial checkpoint: Local repository setup" --allow-empty`;
|
||||
console.log(chalk.green('✅ Initial checkpoint created'));
|
||||
} catch (gitError) {
|
||||
console.log(chalk.yellow(`⚠️ Git setup warning: ${gitError.message}`));
|
||||
const errMsg = gitError instanceof Error ? gitError.message : String(gitError);
|
||||
console.log(chalk.yellow(`⚠️ Git setup warning: ${errMsg}`));
|
||||
// Non-fatal - continue without Git setup
|
||||
}
|
||||
|
||||
@@ -46,11 +47,10 @@ export async function setupLocalRepo(repoPath) {
|
||||
if (error instanceof PentestError) {
|
||||
throw error;
|
||||
}
|
||||
throw new PentestError(
|
||||
`Local repository setup failed: ${error.message}`,
|
||||
'filesystem',
|
||||
false,
|
||||
{ repoPath, originalError: error.message }
|
||||
);
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
throw new PentestError(`Local repository setup failed: ${errMsg}`, 'filesystem', false, {
|
||||
repoPath,
|
||||
originalError: errMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable → Regular
+118
-88
@@ -1,53 +1,81 @@
|
||||
#!/usr/bin/env zx
|
||||
#!/usr/bin/env node
|
||||
// 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 { path, fs } from 'zx';
|
||||
import { path, fs, $ } from 'zx';
|
||||
import chalk from 'chalk';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// Config and Tools
|
||||
import { parseConfig, distributeConfig } from './src/config-parser.js';
|
||||
import { checkToolAvailability, handleMissingTools } from './src/tool-checker.js';
|
||||
import { parseConfig, distributeConfig } from './config-parser.js';
|
||||
import { checkToolAvailability, handleMissingTools } from './tool-checker.js';
|
||||
|
||||
// Session and Checkpoints
|
||||
import { createSession, updateSession, getSession, AGENTS } from './src/session-manager.js';
|
||||
import { runPhase, getGitCommitHash } from './src/checkpoint-manager.js';
|
||||
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';
|
||||
|
||||
// Setup and Deliverables
|
||||
import { setupLocalRepo } from './src/setup/environment.js';
|
||||
import { setupLocalRepo } from './setup/environment.js';
|
||||
|
||||
// AI and Prompts
|
||||
import { runClaudePromptWithRetry } from './src/ai/claude-executor.js';
|
||||
import { loadPrompt } from './src/prompts/prompt-manager.js';
|
||||
import { runClaudePromptWithRetry } from './ai/claude-executor.js';
|
||||
import { loadPrompt } from './prompts/prompt-manager.js';
|
||||
|
||||
// Phases
|
||||
import { executePreReconPhase } from './src/phases/pre-recon.js';
|
||||
import { assembleFinalReport } from './src/phases/reporting.js';
|
||||
import { executePreReconPhase } from './phases/pre-recon.js';
|
||||
import { assembleFinalReport } from './phases/reporting.js';
|
||||
|
||||
// Utils
|
||||
import { timingResults, costResults, displayTimingSummary, Timer } from './src/utils/metrics.js';
|
||||
import { formatDuration, generateAuditPath } from './src/audit/utils.js';
|
||||
import { timingResults, costResults, displayTimingSummary, Timer } from './utils/metrics.js';
|
||||
import { formatDuration, generateAuditPath } from './audit/utils.js';
|
||||
|
||||
// CLI
|
||||
import { handleDeveloperCommand } from './src/cli/command-handler.js';
|
||||
import { showHelp, displaySplashScreen } from './src/cli/ui.js';
|
||||
import { validateWebUrl, validateRepoPath } from './src/cli/input-validator.js';
|
||||
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 './src/error-handling.js';
|
||||
import { PentestError, logError } from './error-handling.js';
|
||||
|
||||
// Session Manager Functions
|
||||
import {
|
||||
calculateVulnerabilityAnalysisSummary,
|
||||
calculateExploitationSummary,
|
||||
getNextAgent
|
||||
} from './src/session-manager.js';
|
||||
} from './session-manager.js';
|
||||
|
||||
import type { DistributedConfig } from './types/config.js';
|
||||
import type { ToolAvailability } from './tool-checker.js';
|
||||
|
||||
// Extend global namespace for SHANNON_DISABLE_LOADER
|
||||
declare global {
|
||||
var SHANNON_DISABLE_LOADER: boolean | undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Configure zx to disable timeouts (let tools run as long as needed)
|
||||
$.timeout = 0;
|
||||
@@ -66,7 +94,13 @@ process.on('SIGTERM', async () => {
|
||||
});
|
||||
|
||||
// Main orchestration function
|
||||
async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = false, disableLoader = false) {
|
||||
async function main(
|
||||
webUrl: string,
|
||||
repoPath: string,
|
||||
configPath: string | null = null,
|
||||
pipelineTestingMode: boolean = false,
|
||||
disableLoader: boolean = false
|
||||
): Promise<MainResult> {
|
||||
// Set global flag for loader control
|
||||
global.SHANNON_DISABLE_LOADER = disableLoader;
|
||||
|
||||
@@ -85,8 +119,7 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
|
||||
// Parse configuration if provided
|
||||
let config = null;
|
||||
let distributedConfig = null;
|
||||
let distributedConfig: DistributedConfig | null = null;
|
||||
if (configPath) {
|
||||
try {
|
||||
// Resolve config path - check configs folder if relative path
|
||||
@@ -100,28 +133,28 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
}
|
||||
}
|
||||
|
||||
config = await parseConfig(resolvedConfigPath);
|
||||
const config = await parseConfig(resolvedConfigPath);
|
||||
distributedConfig = distributeConfig(config);
|
||||
console.log(chalk.green(`✅ Configuration loaded successfully`));
|
||||
} catch (error) {
|
||||
await logError(error, `Configuration loading from ${configPath}`);
|
||||
await logError(error as Error, `Configuration loading from ${configPath}`);
|
||||
throw error; // Let the main error boundary handle it
|
||||
}
|
||||
}
|
||||
|
||||
// Check tool availability
|
||||
const toolAvailability = await checkToolAvailability();
|
||||
const toolAvailability: ToolAvailability = await checkToolAvailability();
|
||||
handleMissingTools(toolAvailability);
|
||||
|
||||
// Setup local repository
|
||||
console.log(chalk.blue('📁 Setting up local repository...'));
|
||||
let sourceDir;
|
||||
let sourceDir: string;
|
||||
try {
|
||||
sourceDir = await setupLocalRepo(repoPath);
|
||||
const variables = { webUrl, repoPath, sourceDir };
|
||||
console.log(chalk.green('✅ Local repository setup successfully'));
|
||||
} catch (error) {
|
||||
console.log(chalk.red(`❌ Failed to setup local repository: ${error.message}`));
|
||||
const err = error as Error;
|
||||
console.log(chalk.red(`❌ Failed to setup local repository: ${err.message}`));
|
||||
console.log(chalk.gray('This could be due to:'));
|
||||
console.log(chalk.gray(' - Insufficient permissions'));
|
||||
console.log(chalk.gray(' - Repository path not accessible'));
|
||||
@@ -130,27 +163,27 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const variables = { webUrl, repoPath, sourceDir };
|
||||
const variables: PromptVariables = { webUrl, repoPath, sourceDir };
|
||||
|
||||
// Create session for tracking (in normal mode)
|
||||
const session = await createSession(webUrl, repoPath, configPath, sourceDir);
|
||||
const session: Session = await createSession(webUrl, repoPath, configPath, sourceDir);
|
||||
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.mjs --run-agent pre-recon'));
|
||||
console.log(chalk.gray(' ./shannon.mjs --status'));
|
||||
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, commitHash = null) => {
|
||||
const updateSessionProgress = async (agentName: AgentName, commitHash: string | null = null): Promise<void> => {
|
||||
try {
|
||||
const updates = {
|
||||
completedAgents: [...new Set([...session.completedAgents, agentName])],
|
||||
failedAgents: session.failedAgents.filter(name => name !== agentName), // Remove from failed if it was there
|
||||
const updates: SessionUpdates = {
|
||||
completedAgents: [...new Set([...session.completedAgents, agentName])] as AgentName[],
|
||||
failedAgents: session.failedAgents.filter(name => name !== agentName),
|
||||
status: 'in-progress'
|
||||
};
|
||||
|
||||
@@ -163,7 +196,8 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
Object.assign(session, updates);
|
||||
console.log(chalk.gray(` 📝 Session updated: ${agentName} completed`));
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Failed to update session: ${error.message}`));
|
||||
const err = error as Error;
|
||||
console.log(chalk.yellow(` ⚠️ Failed to update session: ${err.message}`));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,11 +208,12 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
await fs.ensureDir(path.join(outputsDir, 'schemas'));
|
||||
await fs.ensureDir(path.join(outputsDir, 'scans'));
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
throw new PentestError(
|
||||
`Failed to create output directories: ${error.message}`,
|
||||
`Failed to create output directories: ${err.message}`,
|
||||
'filesystem',
|
||||
false,
|
||||
{ sourceDir, originalError: error.message }
|
||||
{ sourceDir, originalError: err.message }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,7 +221,7 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
const nextAgent = getNextAgent(session);
|
||||
if (!nextAgent) {
|
||||
console.log(chalk.green(`✅ All agents completed! Session is finished.`));
|
||||
await displayTimingSummary(timingResults, costResults, session.completedAgents);
|
||||
displayTimingSummary();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -219,7 +254,7 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
console.log(chalk.magenta.bold('\n🔎 PHASE 2: RECONNAISSANCE'));
|
||||
console.log(chalk.magenta('Analyzing initial findings...'));
|
||||
const reconTimer = new Timer('phase-2-recon');
|
||||
const recon = await runClaudePromptWithRetry(
|
||||
await runClaudePromptWithRetry(
|
||||
await loadPrompt('recon', variables, distributedConfig, pipelineTestingMode),
|
||||
sourceDir,
|
||||
'*',
|
||||
@@ -227,7 +262,7 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
AGENTS['recon'].displayName,
|
||||
'recon', // Agent name for snapshot creation
|
||||
chalk.cyan,
|
||||
{ id: session.id, webUrl } // Session metadata for audit logging (STANDARD: use 'id' field)
|
||||
{ id: session.id, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field)
|
||||
);
|
||||
const reconDuration = reconTimer.stop();
|
||||
timingResults.phases['recon'] = reconDuration;
|
||||
@@ -245,8 +280,10 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
|
||||
// Display vulnerability analysis summary
|
||||
const currentSession = await getSession(session.id);
|
||||
const vulnSummary = calculateVulnerabilityAnalysisSummary(currentSession);
|
||||
console.log(chalk.blue(`\n📊 Vulnerability Analysis Summary: ${vulnSummary.totalAnalyses} analyses, ${vulnSummary.totalVulnerabilities} vulnerabilities found, ${vulnSummary.exploitationCandidates} ready for exploitation`));
|
||||
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 vulnDuration = vulnTimer.stop();
|
||||
timingResults.phases['vulnerability-analysis'] = vulnDuration;
|
||||
@@ -261,15 +298,19 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
|
||||
// Get fresh session data to ensure we have latest vulnerability analysis results
|
||||
const freshSession = await getSession(session.id);
|
||||
await runPhase('exploitation', freshSession, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
|
||||
if (freshSession) {
|
||||
await runPhase('exploitation', freshSession, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt);
|
||||
}
|
||||
|
||||
// Display exploitation summary
|
||||
const finalSession = await getSession(session.id);
|
||||
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)`));
|
||||
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 exploitDuration = exploitTimer.stop();
|
||||
@@ -290,12 +331,13 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
try {
|
||||
await assembleFinalReport(sourceDir);
|
||||
} catch (error) {
|
||||
console.log(chalk.red(`❌ Error assembling final report: ${error.message}`));
|
||||
const err = error as Error;
|
||||
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...'));
|
||||
const execSummary = await runClaudePromptWithRetry(
|
||||
await runClaudePromptWithRetry(
|
||||
await loadPrompt('report-executive', variables, distributedConfig, pipelineTestingMode),
|
||||
sourceDir,
|
||||
'*',
|
||||
@@ -303,7 +345,7 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
'Executive Summary and Report Cleanup',
|
||||
'report', // Agent name for snapshot creation
|
||||
chalk.cyan,
|
||||
{ id: session.id, webUrl } // Session metadata for audit logging (STANDARD: use 'id' field)
|
||||
{ id: session.id, webUrl, repoPath: sourceDir } // Session metadata for audit logging (STANDARD: use 'id' field)
|
||||
);
|
||||
|
||||
const reportDuration = reportTimer.stop();
|
||||
@@ -317,31 +359,18 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
await updateSessionProgress('report', reportCommitHash);
|
||||
console.log(chalk.gray(` 📍 Report checkpoint saved: ${reportCommitHash.substring(0, 8)}`));
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Failed to save report checkpoint: ${error.message}`));
|
||||
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 and cost data
|
||||
const totalDuration = timingResults.total.stop();
|
||||
const timingBreakdown = {
|
||||
total: totalDuration,
|
||||
phases: { ...timingResults.phases },
|
||||
agents: { ...timingResults.agents },
|
||||
commands: { ...timingResults.commands }
|
||||
};
|
||||
timingResults.total.stop();
|
||||
|
||||
// Use accumulated cost data
|
||||
const costBreakdown = {
|
||||
total: costResults.total,
|
||||
agents: { ...costResults.agents }
|
||||
};
|
||||
|
||||
// Mark session as completed with timing and cost data
|
||||
// Mark session as completed
|
||||
await updateSession(session.id, {
|
||||
status: 'completed',
|
||||
timingBreakdown,
|
||||
costBreakdown
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
// Display comprehensive timing summary
|
||||
@@ -351,7 +380,7 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
|
||||
// Calculate audit logs path
|
||||
const auditLogsPath = generateAuditPath(session);
|
||||
const auditLogsPath = generateAuditPath({ id: session.id, webUrl: session.webUrl, repoPath: session.repoPath });
|
||||
|
||||
// Return final report path and audit logs path for clickable output
|
||||
return {
|
||||
@@ -363,22 +392,22 @@ async function main(webUrl, repoPath, configPath = null, pipelineTestingMode = f
|
||||
// Entry point - handle both direct node execution and shebang execution
|
||||
let args = process.argv.slice(2);
|
||||
// If first arg is the script name (from shebang), remove it
|
||||
if (args[0] && args[0].includes('shannon.mjs')) {
|
||||
if (args[0] && args[0].includes('shannon')) {
|
||||
args = args.slice(1);
|
||||
}
|
||||
|
||||
// Parse flags and arguments
|
||||
let configPath = null;
|
||||
let configPath: string | null = null;
|
||||
let pipelineTestingMode = false;
|
||||
let disableLoader = false;
|
||||
const nonFlagArgs = [];
|
||||
let developerCommand = null;
|
||||
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') {
|
||||
if (i + 1 < args.length) {
|
||||
configPath = args[i + 1];
|
||||
configPath = args[i + 1]!;
|
||||
i++; // Skip the next argument
|
||||
} else {
|
||||
console.log(chalk.red('❌ --config flag requires a file path'));
|
||||
@@ -388,8 +417,8 @@ 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];
|
||||
} 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');
|
||||
|
||||
@@ -406,8 +435,8 @@ for (let i = 0; i < args.length; i++) {
|
||||
// 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]);
|
||||
} else if (!args[i]!.startsWith('-')) {
|
||||
nonFlagArgs.push(args[i]!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,15 +466,15 @@ if (nonFlagArgs.length === 0) {
|
||||
// Handle insufficient arguments
|
||||
if (nonFlagArgs.length < 2) {
|
||||
console.log(chalk.red('❌ Both WEB_URL and REPO_PATH are required'));
|
||||
console.log(chalk.gray('Usage: ./shannon.mjs <WEB_URL> <REPO_PATH> [--config config.yaml]'));
|
||||
console.log(chalk.gray('Help: ./shannon.mjs --help'));
|
||||
console.log(chalk.gray('Usage: shannon <WEB_URL> <REPO_PATH> [--config config.yaml]'));
|
||||
console.log(chalk.gray('Help: shannon --help'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [webUrl, repoPath] = nonFlagArgs;
|
||||
|
||||
// Validate web URL
|
||||
const webUrlValidation = validateWebUrl(webUrl);
|
||||
const webUrlValidation = validateWebUrl(webUrl!);
|
||||
if (!webUrlValidation.valid) {
|
||||
console.log(chalk.red(`❌ Invalid web URL: ${webUrlValidation.error}`));
|
||||
console.log(chalk.gray(`Expected format: https://example.com`));
|
||||
@@ -453,7 +482,7 @@ if (!webUrlValidation.valid) {
|
||||
}
|
||||
|
||||
// Validate repository path
|
||||
const repoPathValidation = await validateRepoPath(repoPath);
|
||||
const repoPathValidation = await validateRepoPath(repoPath!);
|
||||
if (!repoPathValidation.valid) {
|
||||
console.log(chalk.red(`❌ Invalid repository path: ${repoPathValidation.error}`));
|
||||
console.log(chalk.gray(`Expected: Accessible local directory path`));
|
||||
@@ -473,7 +502,7 @@ if (disableLoader) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await main(webUrl, repoPathValidation.path, configPath, pipelineTestingMode, disableLoader);
|
||||
const result = await main(webUrl!, repoPathValidation.path!, configPath, pipelineTestingMode, disableLoader);
|
||||
console.log(chalk.green.bold('\n📄 FINAL REPORT AVAILABLE:'));
|
||||
console.log(chalk.cyan(result.reportPath));
|
||||
console.log(chalk.green.bold('\n📂 AUDIT LOGS AVAILABLE:'));
|
||||
@@ -491,13 +520,14 @@ try {
|
||||
console.log(chalk.yellow(' Consider running the command again or checking network connectivity.'));
|
||||
}
|
||||
} else {
|
||||
const err = error as Error;
|
||||
console.log(chalk.red.bold('\n🚨 UNEXPECTED ERROR OCCURRED'));
|
||||
console.log(chalk.red(` Error: ${error?.message || error?.toString() || 'Unknown error'}`));
|
||||
console.log(chalk.red(` Error: ${err?.message || err?.toString() || 'Unknown error'}`));
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.log(chalk.gray(` Stack: ${error?.stack || 'No stack trace available'}`));
|
||||
console.log(chalk.gray(` Stack: ${err?.stack || 'No stack trace available'}`));
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -10,18 +10,18 @@ import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
import { fs, path } from 'zx';
|
||||
|
||||
export const displaySplashScreen = async () => {
|
||||
export const displaySplashScreen = async (): Promise<void> => {
|
||||
try {
|
||||
// Get version info from package.json
|
||||
const packagePath = path.join(import.meta.dirname, '..', 'package.json');
|
||||
const packageJson = await fs.readJSON(packagePath);
|
||||
const packageJson = (await fs.readJSON(packagePath)) as { version?: string };
|
||||
const version = packageJson.version || '1.0.0';
|
||||
|
||||
// Create the main SHANNON ASCII art
|
||||
const shannonText = figlet.textSync('SHANNON', {
|
||||
font: 'ANSI Shadow',
|
||||
horizontalLayout: 'default',
|
||||
verticalLayout: 'default'
|
||||
verticalLayout: 'default',
|
||||
});
|
||||
|
||||
// Apply golden gradient to SHANNON
|
||||
@@ -42,7 +42,7 @@ export const displaySplashScreen = async () => {
|
||||
` ${versionInfo}`,
|
||||
'',
|
||||
chalk.bold.yellow(' 🔐 DEFENSIVE SECURITY ONLY 🔐'),
|
||||
''
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
// Create boxed output with minimal styling
|
||||
@@ -51,7 +51,7 @@ export const displaySplashScreen = async () => {
|
||||
margin: 1,
|
||||
borderStyle: 'double',
|
||||
borderColor: 'cyan',
|
||||
dimBorder: false
|
||||
dimBorder: false,
|
||||
});
|
||||
|
||||
// Clear screen and display splash
|
||||
@@ -64,7 +64,9 @@ export const displaySplashScreen = async () => {
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const loadingInterval = setInterval(() => {
|
||||
process.stdout.write(`\r${chalk.cyan(loadingFrames[frameIndex])} ${chalk.dim('Initializing systems...')}`);
|
||||
process.stdout.write(
|
||||
`\r${chalk.cyan(loadingFrames[frameIndex])} ${chalk.dim('Initializing systems...')}`
|
||||
);
|
||||
frameIndex = (frameIndex + 1) % loadingFrames.length;
|
||||
}, 100);
|
||||
|
||||
@@ -74,11 +76,11 @@ export const displaySplashScreen = async () => {
|
||||
resolve();
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Fallback to simple splash if anything fails
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.cyan.bold('\n🚀 SHANNON - AI Penetration Testing Framework\n'));
|
||||
console.log(chalk.yellow('⚠️ Could not load full splash screen:', error.message));
|
||||
console.log(chalk.yellow('⚠️ Could not load full splash screen:', errMsg));
|
||||
console.log('');
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -7,13 +7,22 @@
|
||||
import { $ } from 'zx';
|
||||
import chalk from 'chalk';
|
||||
|
||||
type ToolName = 'nmap' | 'subfinder' | 'whatweb' | 'schemathesis';
|
||||
|
||||
export type ToolAvailability = Record<ToolName, boolean>;
|
||||
|
||||
// Check availability of required tools
|
||||
export const checkToolAvailability = async () => {
|
||||
const tools = ['nmap', 'subfinder', 'whatweb', 'schemathesis'];
|
||||
const availability = {};
|
||||
|
||||
export const checkToolAvailability = async (): Promise<ToolAvailability> => {
|
||||
const tools: ToolName[] = ['nmap', 'subfinder', 'whatweb', 'schemathesis'];
|
||||
const availability: ToolAvailability = {
|
||||
nmap: false,
|
||||
subfinder: false,
|
||||
whatweb: false,
|
||||
schemathesis: false
|
||||
};
|
||||
|
||||
console.log(chalk.blue('🔧 Checking tool availability...'));
|
||||
|
||||
|
||||
for (const tool of tools) {
|
||||
try {
|
||||
await $`command -v ${tool}`;
|
||||
@@ -24,33 +33,31 @@ export const checkToolAvailability = async () => {
|
||||
console.log(chalk.yellow(` ⚠️ ${tool} - not found`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return availability;
|
||||
};
|
||||
|
||||
// Handle missing tools with user-friendly messages
|
||||
export const handleMissingTools = (toolAvailability) => {
|
||||
const missing = Object.entries(toolAvailability)
|
||||
.filter(([tool, available]) => !available)
|
||||
export const handleMissingTools = (toolAvailability: ToolAvailability): ToolName[] => {
|
||||
const missing = (Object.entries(toolAvailability) as Array<[ToolName, boolean]>)
|
||||
.filter(([, available]) => !available)
|
||||
.map(([tool]) => tool);
|
||||
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.log(chalk.yellow(`\n⚠️ Missing tools: ${missing.join(', ')}`));
|
||||
console.log(chalk.gray('Some functionality will be limited. Install missing tools for full capability.'));
|
||||
|
||||
|
||||
// Provide installation hints
|
||||
const installHints = {
|
||||
const installHints: Record<ToolName, string> = {
|
||||
'nmap': 'brew install nmap (macOS) or apt install nmap (Ubuntu)',
|
||||
'subfinder': 'go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest',
|
||||
'whatweb': 'gem install whatweb',
|
||||
'schemathesis': 'pip install schemathesis'
|
||||
};
|
||||
|
||||
|
||||
console.log(chalk.gray('\nInstallation hints:'));
|
||||
missing.forEach(tool => {
|
||||
if (installHints[tool]) {
|
||||
console.log(chalk.gray(` ${tool}: ${installHints[tool]}`));
|
||||
}
|
||||
console.log(chalk.gray(` ${tool}: ${installHints[tool]}`));
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Agent type definitions
|
||||
*/
|
||||
|
||||
export type AgentName =
|
||||
| 'pre-recon'
|
||||
| 'recon'
|
||||
| 'injection-vuln'
|
||||
| 'xss-vuln'
|
||||
| 'auth-vuln'
|
||||
| 'ssrf-vuln'
|
||||
| 'authz-vuln'
|
||||
| 'injection-exploit'
|
||||
| 'xss-exploit'
|
||||
| 'auth-exploit'
|
||||
| 'ssrf-exploit'
|
||||
| 'authz-exploit'
|
||||
| 'report';
|
||||
|
||||
export type PromptName =
|
||||
| 'pre-recon-code'
|
||||
| 'recon'
|
||||
| 'vuln-injection'
|
||||
| 'vuln-xss'
|
||||
| 'vuln-auth'
|
||||
| 'vuln-ssrf'
|
||||
| 'vuln-authz'
|
||||
| 'exploit-injection'
|
||||
| 'exploit-xss'
|
||||
| 'exploit-auth'
|
||||
| 'exploit-ssrf'
|
||||
| 'exploit-authz'
|
||||
| 'report-executive';
|
||||
|
||||
export type PlaywrightAgent =
|
||||
| 'playwright-agent1'
|
||||
| 'playwright-agent2'
|
||||
| 'playwright-agent3'
|
||||
| 'playwright-agent4'
|
||||
| 'playwright-agent5';
|
||||
|
||||
export type AgentValidator = (sourceDir: string) => Promise<boolean>;
|
||||
|
||||
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';
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Configuration type definitions
|
||||
*/
|
||||
|
||||
export type RuleType =
|
||||
| 'path'
|
||||
| 'subdomain'
|
||||
| 'domain'
|
||||
| 'method'
|
||||
| 'header'
|
||||
| 'parameter';
|
||||
|
||||
export interface Rule {
|
||||
description: string;
|
||||
type: RuleType;
|
||||
url_path: string;
|
||||
}
|
||||
|
||||
export interface Rules {
|
||||
avoid?: Rule[];
|
||||
focus?: Rule[];
|
||||
}
|
||||
|
||||
export type LoginType = 'form' | 'sso' | 'api' | 'basic';
|
||||
|
||||
export type SuccessConditionType = 'url' | 'cookie' | 'element' | 'redirect';
|
||||
|
||||
export interface SuccessCondition {
|
||||
type: SuccessConditionType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
username: string;
|
||||
password: string;
|
||||
totp_secret?: string;
|
||||
}
|
||||
|
||||
export interface Authentication {
|
||||
login_type: LoginType;
|
||||
login_url: string;
|
||||
credentials: Credentials;
|
||||
login_flow: string[];
|
||||
success_condition: SuccessCondition;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
rules?: Rules;
|
||||
authentication?: Authentication;
|
||||
login?: unknown; // Deprecated
|
||||
}
|
||||
|
||||
export interface DistributedConfig {
|
||||
avoid: Rule[];
|
||||
focus: Rule[];
|
||||
authentication: Authentication | null;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Error type definitions
|
||||
*/
|
||||
|
||||
export type PentestErrorType =
|
||||
| 'config'
|
||||
| 'network'
|
||||
| 'tool'
|
||||
| 'prompt'
|
||||
| 'filesystem'
|
||||
| 'validation'
|
||||
| 'billing'
|
||||
| 'unknown';
|
||||
|
||||
export interface PentestErrorContext {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
context: string;
|
||||
error: {
|
||||
name: string;
|
||||
message: string;
|
||||
type: PentestErrorType;
|
||||
retryable: boolean;
|
||||
stack?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolErrorResult {
|
||||
tool: string;
|
||||
output: string;
|
||||
status: 'error';
|
||||
duration: number;
|
||||
success: false;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export interface PromptErrorResult {
|
||||
success: false;
|
||||
error: Error;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Type definitions barrel export
|
||||
*/
|
||||
|
||||
export * from './errors.js';
|
||||
export * from './config.js';
|
||||
export * from './session.js';
|
||||
export * from './agents.js';
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
* concurrent session operations.
|
||||
*/
|
||||
|
||||
type UnlockFunction = () => void;
|
||||
|
||||
/**
|
||||
* SessionMutex - Promise-based mutex for session file operations
|
||||
*
|
||||
@@ -19,7 +21,7 @@
|
||||
* during parallel execution of vulnerability analysis and exploitation phases.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* ```ts
|
||||
* const mutex = new SessionMutex();
|
||||
* const unlock = await mutex.lock(sessionId);
|
||||
* try {
|
||||
@@ -30,31 +32,27 @@
|
||||
* ```
|
||||
*/
|
||||
export class SessionMutex {
|
||||
constructor() {
|
||||
// Map of sessionId -> Promise (represents active lock)
|
||||
this.locks = new Map();
|
||||
}
|
||||
// Map of sessionId -> Promise (represents active lock)
|
||||
private locks: Map<string, Promise<void>> = new Map();
|
||||
|
||||
/**
|
||||
* Acquire lock for a session
|
||||
* @param {string} sessionId - Session ID to lock
|
||||
* @returns {Promise<Function>} Unlock function to release the lock
|
||||
*/
|
||||
async lock(sessionId) {
|
||||
async lock(sessionId: string): Promise<UnlockFunction> {
|
||||
if (this.locks.has(sessionId)) {
|
||||
// Wait for existing lock to be released
|
||||
await this.locks.get(sessionId);
|
||||
}
|
||||
|
||||
// Create new lock promise
|
||||
let resolve;
|
||||
const promise = new Promise(r => resolve = r);
|
||||
let resolve: () => void;
|
||||
const promise = new Promise<void>((r) => (resolve = r));
|
||||
this.locks.set(sessionId, promise);
|
||||
|
||||
// Return unlock function
|
||||
return () => {
|
||||
this.locks.delete(sessionId);
|
||||
resolve();
|
||||
resolve!();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,201 +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 { $ } from 'zx';
|
||||
import chalk from 'chalk';
|
||||
|
||||
// Global git operations semaphore to prevent index.lock conflicts during parallel execution
|
||||
class GitSemaphore {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
async acquire() {
|
||||
return new Promise((resolve) => {
|
||||
this.queue.push(resolve);
|
||||
this.process();
|
||||
});
|
||||
}
|
||||
|
||||
release() {
|
||||
this.running = false;
|
||||
this.process();
|
||||
}
|
||||
|
||||
process() {
|
||||
if (!this.running && this.queue.length > 0) {
|
||||
this.running = true;
|
||||
const resolve = this.queue.shift();
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gitSemaphore = new GitSemaphore();
|
||||
|
||||
// Execute git commands with retry logic for index.lock conflicts
|
||||
export const executeGitCommandWithRetry = async (commandArgs, sourceDir, description, maxRetries = 5) => {
|
||||
await gitSemaphore.acquire();
|
||||
|
||||
try {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Handle both array and string commands
|
||||
let result;
|
||||
if (Array.isArray(commandArgs)) {
|
||||
// For arrays like ['git', 'status', '--porcelain'], execute parts separately
|
||||
const [cmd, ...args] = commandArgs;
|
||||
result = await $`cd ${sourceDir} && ${cmd} ${args}`;
|
||||
} else {
|
||||
// For string commands
|
||||
result = await $`cd ${sourceDir} && ${commandArgs}`;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const isLockError = error.message.includes('index.lock') ||
|
||||
error.message.includes('unable to lock') ||
|
||||
error.message.includes('Another git process') ||
|
||||
error.message.includes('fatal: Unable to create') ||
|
||||
error.message.includes('fatal: index file');
|
||||
|
||||
if (isLockError && attempt < maxRetries) {
|
||||
const delay = Math.pow(2, attempt - 1) * 1000; // Exponential backoff: 1s, 2s, 4s, 8s, 16s
|
||||
console.log(chalk.yellow(` ⚠️ Git lock conflict during ${description} (attempt ${attempt}/${maxRetries}). Retrying in ${delay}ms...`));
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
gitSemaphore.release();
|
||||
}
|
||||
};
|
||||
|
||||
// Pure functions for Git workspace management
|
||||
const cleanWorkspace = async (sourceDir, reason = 'clean start') => {
|
||||
console.log(chalk.blue(` 🧹 Cleaning workspace for ${reason}`));
|
||||
try {
|
||||
// Check for uncommitted changes
|
||||
const status = await $`cd ${sourceDir} && git status --porcelain`;
|
||||
const hasChanges = status.stdout.trim().length > 0;
|
||||
|
||||
if (hasChanges) {
|
||||
// Show what we're about to remove
|
||||
const changes = status.stdout.trim().split('\n').filter(line => line.length > 0);
|
||||
console.log(chalk.yellow(` 🔄 Rolling back workspace for ${reason}`));
|
||||
|
||||
await $`cd ${sourceDir} && git reset --hard HEAD`;
|
||||
await $`cd ${sourceDir} && git clean -fd`;
|
||||
|
||||
console.log(chalk.yellow(` ✅ Rollback completed - removed ${changes.length} contaminated changes:`));
|
||||
changes.slice(0, 3).forEach(change => console.log(chalk.gray(` ${change}`)));
|
||||
if (changes.length > 3) {
|
||||
console.log(chalk.gray(` ... and ${changes.length - 3} more files`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.blue(` ✅ Workspace already clean (no changes to remove)`));
|
||||
}
|
||||
return { success: true, hadChanges: hasChanges };
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Workspace cleanup failed: ${error.message}`));
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
|
||||
export const createGitCheckpoint = async (sourceDir, description, attempt) => {
|
||||
console.log(chalk.blue(` 📍 Creating checkpoint for ${description} (attempt ${attempt})`));
|
||||
try {
|
||||
// Only clean workspace on retry attempts (attempt > 1), not on first attempts
|
||||
// This preserves deliverables between agents while still cleaning on actual retries
|
||||
if (attempt > 1) {
|
||||
const cleanResult = await cleanWorkspace(sourceDir, `${description} (retry cleanup)`);
|
||||
if (!cleanResult.success) {
|
||||
console.log(chalk.yellow(` ⚠️ Workspace cleanup failed, continuing anyway: ${cleanResult.error.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for uncommitted changes with retry logic
|
||||
const status = await executeGitCommandWithRetry(['git', 'status', '--porcelain'], sourceDir, 'status check');
|
||||
const hasChanges = status.stdout.trim().length > 0;
|
||||
|
||||
// Stage changes with retry logic
|
||||
await executeGitCommandWithRetry(['git', 'add', '-A'], sourceDir, 'staging changes');
|
||||
|
||||
// Create commit with retry logic
|
||||
await executeGitCommandWithRetry(['git', 'commit', '-m', `📍 Checkpoint: ${description} (attempt ${attempt})`, '--allow-empty'], sourceDir, 'creating commit');
|
||||
|
||||
if (hasChanges) {
|
||||
console.log(chalk.blue(` ✅ Checkpoint created with uncommitted changes staged`));
|
||||
} else {
|
||||
console.log(chalk.blue(` ✅ Empty checkpoint created (no workspace changes)`));
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Checkpoint creation failed after retries: ${error.message}`));
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
|
||||
export const commitGitSuccess = async (sourceDir, description) => {
|
||||
console.log(chalk.green(` 💾 Committing successful results for ${description}`));
|
||||
try {
|
||||
// Check what we're about to commit with retry logic
|
||||
const status = await executeGitCommandWithRetry(['git', 'status', '--porcelain'], sourceDir, 'status check for success commit');
|
||||
const changes = status.stdout.trim().split('\n').filter(line => line.length > 0);
|
||||
|
||||
// Stage changes with retry logic
|
||||
await executeGitCommandWithRetry(['git', 'add', '-A'], sourceDir, 'staging changes for success commit');
|
||||
|
||||
// Create success commit with retry logic
|
||||
await executeGitCommandWithRetry(['git', 'commit', '-m', `✅ ${description}: completed successfully`, '--allow-empty'], sourceDir, 'creating success commit');
|
||||
|
||||
if (changes.length > 0) {
|
||||
console.log(chalk.green(` ✅ Success commit created with ${changes.length} file changes:`));
|
||||
changes.slice(0, 5).forEach(change => console.log(chalk.gray(` ${change}`)));
|
||||
if (changes.length > 5) {
|
||||
console.log(chalk.gray(` ... and ${changes.length - 5} more files`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.green(` ✅ Empty success commit created (agent made no file changes)`));
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.log(chalk.yellow(` ⚠️ Success commit failed after retries: ${error.message}`));
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
|
||||
export const rollbackGitWorkspace = async (sourceDir, reason = 'retry preparation') => {
|
||||
console.log(chalk.yellow(` 🔄 Rolling back workspace for ${reason}`));
|
||||
try {
|
||||
// Show what we're about to remove with retry logic
|
||||
const status = await executeGitCommandWithRetry(['git', 'status', '--porcelain'], sourceDir, 'status check for rollback');
|
||||
const changes = status.stdout.trim().split('\n').filter(line => line.length > 0);
|
||||
|
||||
// Reset to HEAD with retry logic
|
||||
await executeGitCommandWithRetry(['git', 'reset', '--hard', 'HEAD'], sourceDir, 'hard reset for rollback');
|
||||
|
||||
// Clean untracked files with retry logic
|
||||
await executeGitCommandWithRetry(['git', 'clean', '-fd'], sourceDir, 'cleaning untracked files for rollback');
|
||||
|
||||
if (changes.length > 0) {
|
||||
console.log(chalk.yellow(` ✅ Rollback completed - removed ${changes.length} contaminated changes:`));
|
||||
changes.slice(0, 3).forEach(change => console.log(chalk.gray(` ${change}`)));
|
||||
if (changes.length > 3) {
|
||||
console.log(chalk.gray(` ... and ${changes.length - 3} more files`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.yellow(` ✅ Rollback completed - no changes to remove`));
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.log(chalk.red(` ❌ Rollback failed after retries: ${error.message}`));
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
// 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 { $ } from 'zx';
|
||||
import chalk from 'chalk';
|
||||
|
||||
interface GitOperationResult {
|
||||
success: boolean;
|
||||
hadChanges?: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
// Global git operations semaphore to prevent index.lock conflicts during parallel execution
|
||||
class GitSemaphore {
|
||||
private queue: Array<() => void> = [];
|
||||
private running: boolean = false;
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.queue.push(resolve);
|
||||
this.process();
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.running = false;
|
||||
this.process();
|
||||
}
|
||||
|
||||
private process(): void {
|
||||
if (!this.running && this.queue.length > 0) {
|
||||
this.running = true;
|
||||
const resolve = this.queue.shift();
|
||||
resolve!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gitSemaphore = new GitSemaphore();
|
||||
|
||||
// Execute git commands with retry logic for index.lock conflicts
|
||||
export const executeGitCommandWithRetry = async (
|
||||
commandArgs: string[],
|
||||
sourceDir: string,
|
||||
description: string,
|
||||
maxRetries: number = 5
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
await gitSemaphore.acquire();
|
||||
|
||||
try {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// For arrays like ['git', 'status', '--porcelain'], execute parts separately
|
||||
const [cmd, ...args] = commandArgs;
|
||||
const result = await $`cd ${sourceDir} && ${cmd} ${args}`;
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
const isLockError =
|
||||
errMsg.includes('index.lock') ||
|
||||
errMsg.includes('unable to lock') ||
|
||||
errMsg.includes('Another git process') ||
|
||||
errMsg.includes('fatal: Unable to create') ||
|
||||
errMsg.includes('fatal: index file');
|
||||
|
||||
if (isLockError && attempt < maxRetries) {
|
||||
const delay = Math.pow(2, attempt - 1) * 1000; // Exponential backoff: 1s, 2s, 4s, 8s, 16s
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
` ⚠️ Git lock conflict during ${description} (attempt ${attempt}/${maxRetries}). Retrying in ${delay}ms...`
|
||||
)
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Should never reach here but TypeScript needs a return
|
||||
throw new Error(`Git command failed after ${maxRetries} retries`);
|
||||
} finally {
|
||||
gitSemaphore.release();
|
||||
}
|
||||
};
|
||||
|
||||
// Pure functions for Git workspace management
|
||||
const cleanWorkspace = async (
|
||||
sourceDir: string,
|
||||
reason: string = 'clean start'
|
||||
): Promise<GitOperationResult> => {
|
||||
console.log(chalk.blue(` 🧹 Cleaning workspace for ${reason}`));
|
||||
try {
|
||||
// Check for uncommitted changes
|
||||
const status = await $`cd ${sourceDir} && git status --porcelain`;
|
||||
const hasChanges = status.stdout.trim().length > 0;
|
||||
|
||||
if (hasChanges) {
|
||||
// Show what we're about to remove
|
||||
const changes = status.stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0);
|
||||
console.log(chalk.yellow(` 🔄 Rolling back workspace for ${reason}`));
|
||||
|
||||
await $`cd ${sourceDir} && git reset --hard HEAD`;
|
||||
await $`cd ${sourceDir} && git clean -fd`;
|
||||
|
||||
console.log(
|
||||
chalk.yellow(` ✅ Rollback completed - removed ${changes.length} contaminated changes:`)
|
||||
);
|
||||
changes.slice(0, 3).forEach((change) => console.log(chalk.gray(` ${change}`)));
|
||||
if (changes.length > 3) {
|
||||
console.log(chalk.gray(` ... and ${changes.length - 3} more files`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.blue(` ✅ Workspace already clean (no changes to remove)`));
|
||||
}
|
||||
return { success: true, hadChanges: hasChanges };
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.yellow(` ⚠️ Workspace cleanup failed: ${errMsg}`));
|
||||
return { success: false, error: error instanceof Error ? error : new Error(errMsg) };
|
||||
}
|
||||
};
|
||||
|
||||
export const createGitCheckpoint = async (
|
||||
sourceDir: string,
|
||||
description: string,
|
||||
attempt: number
|
||||
): Promise<GitOperationResult> => {
|
||||
console.log(chalk.blue(` 📍 Creating checkpoint for ${description} (attempt ${attempt})`));
|
||||
try {
|
||||
// Only clean workspace on retry attempts (attempt > 1), not on first attempts
|
||||
// This preserves deliverables between agents while still cleaning on actual retries
|
||||
if (attempt > 1) {
|
||||
const cleanResult = await cleanWorkspace(sourceDir, `${description} (retry cleanup)`);
|
||||
if (!cleanResult.success) {
|
||||
const errMsg = cleanResult.error?.message || 'Unknown error';
|
||||
console.log(
|
||||
chalk.yellow(` ⚠️ Workspace cleanup failed, continuing anyway: ${errMsg}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for uncommitted changes with retry logic
|
||||
const status = await executeGitCommandWithRetry(
|
||||
['git', 'status', '--porcelain'],
|
||||
sourceDir,
|
||||
'status check'
|
||||
);
|
||||
const hasChanges = status.stdout.trim().length > 0;
|
||||
|
||||
// Stage changes with retry logic
|
||||
await executeGitCommandWithRetry(['git', 'add', '-A'], sourceDir, 'staging changes');
|
||||
|
||||
// Create commit with retry logic
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'commit', '-m', `📍 Checkpoint: ${description} (attempt ${attempt})`, '--allow-empty'],
|
||||
sourceDir,
|
||||
'creating commit'
|
||||
);
|
||||
|
||||
if (hasChanges) {
|
||||
console.log(chalk.blue(` ✅ Checkpoint created with uncommitted changes staged`));
|
||||
} else {
|
||||
console.log(chalk.blue(` ✅ Empty checkpoint created (no workspace changes)`));
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.yellow(` ⚠️ Checkpoint creation failed after retries: ${errMsg}`));
|
||||
return { success: false, error: error instanceof Error ? error : new Error(errMsg) };
|
||||
}
|
||||
};
|
||||
|
||||
export const commitGitSuccess = async (
|
||||
sourceDir: string,
|
||||
description: string
|
||||
): Promise<GitOperationResult> => {
|
||||
console.log(chalk.green(` 💾 Committing successful results for ${description}`));
|
||||
try {
|
||||
// Check what we're about to commit with retry logic
|
||||
const status = await executeGitCommandWithRetry(
|
||||
['git', 'status', '--porcelain'],
|
||||
sourceDir,
|
||||
'status check for success commit'
|
||||
);
|
||||
const changes = status.stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
// Stage changes with retry logic
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'add', '-A'],
|
||||
sourceDir,
|
||||
'staging changes for success commit'
|
||||
);
|
||||
|
||||
// Create success commit with retry logic
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'commit', '-m', `✅ ${description}: completed successfully`, '--allow-empty'],
|
||||
sourceDir,
|
||||
'creating success commit'
|
||||
);
|
||||
|
||||
if (changes.length > 0) {
|
||||
console.log(chalk.green(` ✅ Success commit created with ${changes.length} file changes:`));
|
||||
changes.slice(0, 5).forEach((change) => console.log(chalk.gray(` ${change}`)));
|
||||
if (changes.length > 5) {
|
||||
console.log(chalk.gray(` ... and ${changes.length - 5} more files`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.green(` ✅ Empty success commit created (agent made no file changes)`));
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.yellow(` ⚠️ Success commit failed after retries: ${errMsg}`));
|
||||
return { success: false, error: error instanceof Error ? error : new Error(errMsg) };
|
||||
}
|
||||
};
|
||||
|
||||
export const rollbackGitWorkspace = async (
|
||||
sourceDir: string,
|
||||
reason: string = 'retry preparation'
|
||||
): Promise<GitOperationResult> => {
|
||||
console.log(chalk.yellow(` 🔄 Rolling back workspace for ${reason}`));
|
||||
try {
|
||||
// Show what we're about to remove with retry logic
|
||||
const status = await executeGitCommandWithRetry(
|
||||
['git', 'status', '--porcelain'],
|
||||
sourceDir,
|
||||
'status check for rollback'
|
||||
);
|
||||
const changes = status.stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
// Reset to HEAD with retry logic
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'reset', '--hard', 'HEAD'],
|
||||
sourceDir,
|
||||
'hard reset for rollback'
|
||||
);
|
||||
|
||||
// Clean untracked files with retry logic
|
||||
await executeGitCommandWithRetry(
|
||||
['git', 'clean', '-fd'],
|
||||
sourceDir,
|
||||
'cleaning untracked files for rollback'
|
||||
);
|
||||
|
||||
if (changes.length > 0) {
|
||||
console.log(
|
||||
chalk.yellow(` ✅ Rollback completed - removed ${changes.length} contaminated changes:`)
|
||||
);
|
||||
changes.slice(0, 3).forEach((change) => console.log(chalk.gray(` ${change}`)));
|
||||
if (changes.length > 3) {
|
||||
console.log(chalk.gray(` ... and ${changes.length - 3} more files`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.yellow(` ✅ Rollback completed - no changes to remove`));
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error);
|
||||
console.log(chalk.red(` ❌ Rollback failed after retries: ${errMsg}`));
|
||||
return { success: false, error: error instanceof Error ? error : new Error(errMsg) };
|
||||
}
|
||||
};
|
||||
@@ -10,38 +10,74 @@ import { formatDuration } from '../audit/utils.js';
|
||||
// Timing utilities
|
||||
|
||||
export class Timer {
|
||||
constructor(name) {
|
||||
name: string;
|
||||
startTime: number;
|
||||
endTime: number | null = null;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
this.startTime = Date.now();
|
||||
this.endTime = null;
|
||||
}
|
||||
|
||||
stop() {
|
||||
stop(): number {
|
||||
this.endTime = Date.now();
|
||||
return this.duration();
|
||||
}
|
||||
|
||||
duration() {
|
||||
duration(): number {
|
||||
const end = this.endTime || Date.now();
|
||||
return end - this.startTime;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface CostResultsAgents {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
interface CostResults {
|
||||
agents: CostResultsAgents;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Global timing and cost tracker
|
||||
export const timingResults = {
|
||||
export const timingResults: TimingResults = {
|
||||
total: null,
|
||||
phases: {},
|
||||
commands: {},
|
||||
agents: {}
|
||||
agents: {},
|
||||
};
|
||||
|
||||
export const costResults = {
|
||||
export const costResults: CostResults = {
|
||||
agents: {},
|
||||
total: 0
|
||||
total: 0,
|
||||
};
|
||||
|
||||
// Function to display comprehensive timing summary
|
||||
export const displayTimingSummary = () => {
|
||||
export const displayTimingSummary = (): void => {
|
||||
if (!timingResults.total) {
|
||||
console.log(chalk.yellow('No timing data available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDuration = timingResults.total.stop();
|
||||
|
||||
console.log(chalk.cyan.bold('\n⏱️ TIMING SUMMARY'));
|
||||
@@ -57,10 +93,16 @@ export const displayTimingSummary = () => {
|
||||
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}%)`));
|
||||
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(
|
||||
chalk.gray(
|
||||
` ${'Phases Total'.padEnd(20)} ${formatDuration(phaseTotal).padStart(8)} (${((phaseTotal / totalDuration) * 100).toFixed(1)}%)`
|
||||
)
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -70,10 +112,16 @@ export const displayTimingSummary = () => {
|
||||
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}%)`));
|
||||
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(
|
||||
chalk.gray(
|
||||
` ${'Commands Total'.padEnd(20)} ${formatDuration(commandTotal).padStart(8)} (${((commandTotal / totalDuration) * 100).toFixed(1)}%)`
|
||||
)
|
||||
);
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -84,10 +132,18 @@ export const displayTimingSummary = () => {
|
||||
for (const [agent, duration] of Object.entries(timingResults.agents)) {
|
||||
const percentage = ((duration / totalDuration) * 100).toFixed(1);
|
||||
const displayName = agent.replace(/-/g, ' ');
|
||||
console.log(chalk.magenta(` ${displayName.padEnd(20)} ${formatDuration(duration).padStart(8)} (${percentage}%)`));
|
||||
console.log(
|
||||
chalk.magenta(
|
||||
` ${displayName.padEnd(20)} ${formatDuration(duration).padStart(8)} (${percentage}%)`
|
||||
)
|
||||
);
|
||||
agentTotal += duration;
|
||||
}
|
||||
console.log(chalk.gray(` ${'Agents Total'.padEnd(20)} ${formatDuration(agentTotal).padStart(8)} (${((agentTotal / totalDuration) * 100).toFixed(1)}%)`));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
` ${'Agents Total'.padEnd(20)} ${formatDuration(agentTotal).padStart(8)} (${((agentTotal / totalDuration) * 100).toFixed(1)}%)`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Cost breakdown
|
||||
@@ -101,4 +157,4 @@ export const displayTimingSummary = () => {
|
||||
}
|
||||
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
};
|
||||
};
|
||||
@@ -6,10 +6,30 @@
|
||||
|
||||
import { AGENTS } from '../session-manager.js';
|
||||
|
||||
interface ToolCallInput {
|
||||
url?: string;
|
||||
element?: string;
|
||||
key?: string;
|
||||
fields?: unknown[];
|
||||
text?: string;
|
||||
action?: string;
|
||||
description?: string;
|
||||
todos?: Array<{
|
||||
status: string;
|
||||
content: string;
|
||||
}>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
name: string;
|
||||
input?: ToolCallInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from URL for display
|
||||
*/
|
||||
function extractDomain(url) {
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname || url.slice(0, 30);
|
||||
@@ -21,24 +41,24 @@ function extractDomain(url) {
|
||||
/**
|
||||
* Summarize TodoWrite updates into clean progress indicators
|
||||
*/
|
||||
function summarizeTodoUpdate(input) {
|
||||
function summarizeTodoUpdate(input: ToolCallInput | undefined): string | null {
|
||||
if (!input?.todos || !Array.isArray(input.todos)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const todos = input.todos;
|
||||
const completed = todos.filter(t => t.status === 'completed');
|
||||
const inProgress = todos.filter(t => t.status === 'in_progress');
|
||||
const completed = todos.filter((t) => t.status === 'completed');
|
||||
const inProgress = todos.filter((t) => t.status === 'in_progress');
|
||||
|
||||
// Show recently completed tasks
|
||||
if (completed.length > 0) {
|
||||
const recent = completed[completed.length - 1];
|
||||
const recent = completed[completed.length - 1]!;
|
||||
return `✅ ${recent.content}`;
|
||||
}
|
||||
|
||||
// Show current in-progress task
|
||||
if (inProgress.length > 0) {
|
||||
const current = inProgress[0];
|
||||
const current = inProgress[0]!;
|
||||
return `🔄 ${current.content}`;
|
||||
}
|
||||
|
||||
@@ -48,9 +68,9 @@ function summarizeTodoUpdate(input) {
|
||||
/**
|
||||
* Get agent prefix for parallel execution
|
||||
*/
|
||||
export function getAgentPrefix(description) {
|
||||
export function getAgentPrefix(description: string): string {
|
||||
// Map agent names to their prefixes
|
||||
const agentPrefixes = {
|
||||
const agentPrefixes: Record<string, string> = {
|
||||
'injection-vuln': '[Injection]',
|
||||
'xss-vuln': '[XSS]',
|
||||
'auth-vuln': '[Auth]',
|
||||
@@ -60,12 +80,13 @@ export function getAgentPrefix(description) {
|
||||
'xss-exploit': '[XSS]',
|
||||
'auth-exploit': '[Auth]',
|
||||
'authz-exploit': '[Authz]',
|
||||
'ssrf-exploit': '[SSRF]'
|
||||
'ssrf-exploit': '[SSRF]',
|
||||
};
|
||||
|
||||
// First try to match by agent name directly
|
||||
for (const [agentName, prefix] of Object.entries(agentPrefixes)) {
|
||||
if (AGENTS[agentName] && description.includes(AGENTS[agentName].displayName)) {
|
||||
const agent = AGENTS[agentName as keyof typeof AGENTS];
|
||||
if (agent && description.includes(agent.displayName)) {
|
||||
return prefix;
|
||||
}
|
||||
}
|
||||
@@ -73,7 +94,7 @@ export function getAgentPrefix(description) {
|
||||
// Fallback to partial matches for backwards compatibility
|
||||
if (description.includes('injection')) return '[Injection]';
|
||||
if (description.includes('xss')) return '[XSS]';
|
||||
if (description.includes('authz')) return '[Authz]'; // Check authz before auth
|
||||
if (description.includes('authz')) return '[Authz]'; // Check authz before auth
|
||||
if (description.includes('auth')) return '[Auth]';
|
||||
if (description.includes('ssrf')) return '[SSRF]';
|
||||
|
||||
@@ -83,7 +104,7 @@ export function getAgentPrefix(description) {
|
||||
/**
|
||||
* Format browser tool calls into clean progress indicators
|
||||
*/
|
||||
function formatBrowserAction(toolCall) {
|
||||
function formatBrowserAction(toolCall: ToolCall): string {
|
||||
const toolName = toolCall.name;
|
||||
const input = toolCall.input || {};
|
||||
|
||||
@@ -181,13 +202,13 @@ function formatBrowserAction(toolCall) {
|
||||
/**
|
||||
* Filter out JSON tool calls from content, with special handling for Task calls
|
||||
*/
|
||||
export function filterJsonToolCalls(content) {
|
||||
export function filterJsonToolCalls(content: string | null | undefined): string {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return content;
|
||||
return content || '';
|
||||
}
|
||||
|
||||
const lines = content.split('\n');
|
||||
const processedLines = [];
|
||||
const processedLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
@@ -200,7 +221,7 @@ export function filterJsonToolCalls(content) {
|
||||
// Check if this is a JSON tool call
|
||||
if (trimmed.startsWith('{"type":"tool_use"')) {
|
||||
try {
|
||||
const toolCall = JSON.parse(trimmed);
|
||||
const toolCall = JSON.parse(trimmed) as ToolCall;
|
||||
|
||||
// Special handling for Task tool calls
|
||||
if (toolCall.name === 'Task') {
|
||||
@@ -229,8 +250,7 @@ export function filterJsonToolCalls(content) {
|
||||
|
||||
// Hide all other tool calls (Read, Write, Grep, etc.)
|
||||
continue;
|
||||
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// If JSON parsing fails, treat as regular text
|
||||
processedLines.push(line);
|
||||
}
|
||||
@@ -241,4 +261,4 @@ export function filterJsonToolCalls(content) {
|
||||
}
|
||||
|
||||
return processedLines.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
// Visit https://aka.ms/tsconfig to read more about this file
|
||||
"compilerOptions": {
|
||||
// File Layout
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
|
||||
// Environment Settings
|
||||
// See also https://aka.ms/tsconfig/module
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
|
||||
"target": "es2022",
|
||||
"lib": ["es2022"],
|
||||
|
||||
"types": ["node"],
|
||||
// For nodejs:
|
||||
// "lib": ["esnext"],
|
||||
// "types": ["node"],
|
||||
// and npm install -D @types/node
|
||||
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmitOnError": true,
|
||||
|
||||
// Other Outputs
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
// Stricter Typechecking Options
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
||||
// Style Options
|
||||
// "noImplicitReturns": true,
|
||||
// "noImplicitOverride": true,
|
||||
// "noUnusedLocals": true,
|
||||
// "noUnusedParameters": true,
|
||||
// "noFallthroughCasesInSwitch": true,
|
||||
// "noPropertyAccessFromIndexSignature": true,
|
||||
|
||||
// Recommended Options
|
||||
"strict": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"mcp-server"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user