From dd18f4629b99487f2898fe0efaaa7a2ad0af70fb Mon Sep 17 00:00:00 2001 From: ezl-keygraph Date: Thu, 8 Jan 2026 00:18:25 +0530 Subject: [PATCH] feat: typescript migration (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.5 --- .gitignore | 3 +- CLAUDE.md | 95 +-- Dockerfile | 24 +- mcp-server/package-lock.json | 35 + mcp-server/package.json | 7 +- mcp-server/src/{index.js => index.ts} | 9 +- .../{generate-totp.js => generate-totp.ts} | 41 +- ...ave-deliverable.js => save-deliverable.ts} | 15 +- .../{deliverables.js => deliverables.ts} | 79 +- mcp-server/src/types/{index.js => index.ts} | 0 mcp-server/src/types/tool-responses.js | 64 -- mcp-server/src/types/tool-responses.ts | 73 ++ ...{error-formatter.js => error-formatter.ts} | 48 +- ...{file-operations.js => file-operations.ts} | 12 +- ...{queue-validator.js => queue-validator.ts} | 36 +- .../{totp-validator.js => totp-validator.ts} | 12 +- mcp-server/tsconfig.json | 50 ++ package-lock.json | 45 +- package.json | 12 +- ...{claude-executor.js => claude-executor.ts} | 611 ++++++++------- .../{audit-session.js => audit-session.ts} | 81 +- src/audit/{index.js => index.ts} | 0 src/audit/{logger.js => logger.ts} | 79 +- ...{metrics-tracker.js => metrics-tracker.ts} | 227 +++--- src/audit/{utils.js => utils.ts} | 78 +- ...point-manager.js => checkpoint-manager.ts} | 706 +++++++++--------- ...{command-handler.js => command-handler.ts} | 65 +- ...{input-validator.js => input-validator.ts} | 21 +- src/cli/{prompts.js => prompts.ts} | 36 +- src/cli/ui.js | 67 -- src/cli/ui.ts | 81 ++ src/{config-parser.js => config-parser.ts} | 206 +++-- src/{constants.js => constants.ts} | 42 +- src/{error-handling.js => error-handling.ts} | 168 +++-- src/phases/{pre-recon.js => pre-recon.ts} | 194 +++-- src/phases/{reporting.js => reporting.ts} | 22 +- ...ess-indicator.js => progress-indicator.ts} | 24 +- .../{prompt-manager.js => prompt-manager.ts} | 66 +- ...ueue-validation.js => queue-validation.ts} | 249 ++++-- ...{session-manager.js => session-manager.ts} | 343 ++++++--- src/setup/{environment.js => environment.ts} | 18 +- shannon.mjs => src/shannon.ts | 206 ++--- src/{splash-screen.js => splash-screen.ts} | 20 +- src/{tool-checker.js => tool-checker.ts} | 39 +- src/types/agents.ts | 73 ++ src/types/config.ts | 63 ++ src/types/errors.ts | 49 ++ src/types/index.ts | 14 + src/types/session.ts | 63 ++ src/utils/{concurrency.js => concurrency.ts} | 20 +- src/utils/git-manager.js | 201 ----- src/utils/git-manager.ts | 276 +++++++ src/utils/{metrics.js => metrics.ts} | 88 ++- ...utput-formatter.js => output-formatter.ts} | 58 +- tsconfig.json | 56 ++ 55 files changed, 3213 insertions(+), 2057 deletions(-) rename mcp-server/src/{index.js => index.ts} (85%) rename mcp-server/src/tools/{generate-totp.js => generate-totp.ts} (74%) rename mcp-server/src/tools/{save-deliverable.js => save-deliverable.ts} (86%) rename mcp-server/src/types/{deliverables.js => deliverables.ts} (56%) rename mcp-server/src/types/{index.js => index.ts} (100%) delete mode 100644 mcp-server/src/types/tool-responses.js create mode 100644 mcp-server/src/types/tool-responses.ts rename mcp-server/src/utils/{error-formatter.js => error-formatter.ts} (50%) rename mcp-server/src/utils/{file-operations.js => file-operations.ts} (81%) rename mcp-server/src/validation/{queue-validator.js => queue-validator.ts} (60%) rename mcp-server/src/validation/{totp-validator.js => totp-validator.ts} (83%) create mode 100644 mcp-server/tsconfig.json rename src/ai/{claude-executor.js => claude-executor.ts} (54%) rename src/audit/{audit-session.js => audit-session.ts} (65%) rename src/audit/{index.js => index.ts} (100%) rename src/audit/{logger.js => logger.ts} (71%) rename src/audit/{metrics-tracker.js => metrics-tracker.ts} (56%) rename src/audit/{utils.js => utils.ts} (58%) rename src/{checkpoint-manager.js => checkpoint-manager.ts} (63%) rename src/cli/{command-handler.js => command-handler.ts} (69%) rename src/cli/{input-validator.js => input-validator.ts} (78%) rename src/cli/{prompts.js => prompts.ts} (58%) delete mode 100644 src/cli/ui.js create mode 100644 src/cli/ui.ts rename src/{config-parser.js => config-parser.ts} (57%) rename src/{constants.js => constants.ts} (71%) rename src/{error-handling.js => error-handling.ts} (53%) rename src/phases/{pre-recon.js => pre-recon.ts} (65%) rename src/phases/{reporting.js => reporting.ts} (81%) rename src/{progress-indicator.js => progress-indicator.ts} (63%) rename src/prompts/{prompt-manager.js => prompt-manager.ts} (80%) rename src/{queue-validation.js => queue-validation.ts} (51%) rename src/{session-manager.js => session-manager.ts} (71%) rename src/setup/{environment.js => environment.ts} (79%) rename shannon.mjs => src/shannon.ts (72%) mode change 100755 => 100644 rename src/{splash-screen.js => splash-screen.ts} (86%) rename src/{tool-checker.js => tool-checker.ts} (65%) create mode 100644 src/types/agents.ts create mode 100644 src/types/config.ts create mode 100644 src/types/errors.ts create mode 100644 src/types/index.ts create mode 100644 src/types/session.ts rename src/utils/{concurrency.js => concurrency.ts} (78%) delete mode 100644 src/utils/git-manager.js create mode 100644 src/utils/git-manager.ts rename src/utils/{metrics.js => metrics.ts} (58%) rename src/utils/{output-formatter.js => output-formatter.ts} (82%) create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index c8ca22d..23d0423 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ .shannon-store.json agent-logs/ -/audit-logs/ \ No newline at end of file +/audit-logs/ +dist/ diff --git a/CLAUDE.md b/CLAUDE.md index 8e38e36..f5a7321 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,13 +15,13 @@ npm install ### Running the Penetration Testing Agent ```bash -./shannon.mjs --config +shannon --config ``` 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 --config ### 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 --pipeline-testing +shannon --pipeline-testing ``` ### Session Management Commands ```bash # Setup session without running -./shannon.mjs --setup-only --config +shannon --setup-only --config # 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 [--pipeline-testing] +shannon --run-agent [--pipeline-testing] # Run a range of agents -./shannon.mjs --run-agents : [--pipeline-testing] +shannon --run-agents : [--pipeline-testing] # Run a specific phase -./shannon.mjs --run-phase [--pipeline-testing] +shannon --run-phase [--pipeline-testing] # Pipeline testing mode (minimal prompts for fast testing) -./shannon.mjs --pipeline-testing +shannon --pipeline-testing ``` ### Rollback & Recovery Commands ```bash # Rollback to specific checkpoint -./shannon.mjs --rollback-to +shannon --rollback-to # Rollback and re-execute specific agent -./shannon.mjs --rerun [--pipeline-testing] +shannon --rerun [--pipeline-testing] ``` ### Session Cleanup Commands ```bash # Delete all sessions (with confirmation) -./shannon.mjs --cleanup +shannon --cleanup # Delete specific session by ID -./shannon.mjs --cleanup +shannon --cleanup ``` ## 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 diff --git a/Dockerfile b/Dockerfile index c8c353a..9e7e6f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +ENTRYPOINT ["node", "dist/shannon.js"] diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 38044b8..1374377 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -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", diff --git a/mcp-server/package.json b/mcp-server/package.json index 09fd3e3..f3e8ac3 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -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" } } diff --git a/mcp-server/src/index.js b/mcp-server/src/index.ts similarity index 85% rename from mcp-server/src/index.js rename to mcp-server/src/index.ts index c35da78..934f61b 100644 --- a/mcp-server/src/index.js +++ b/mcp-server/src/index.ts @@ -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 { // Store target directory for tool access global.__SHANNON_TARGET_DIR = targetDir; diff --git a/mcp-server/src/tools/generate-totp.js b/mcp-server/src/tools/generate-totp.ts similarity index 74% rename from mcp-server/src/tools/generate-totp.js rename to mcp-server/src/tools/generate-totp.ts index 90027e0..7f89da7 100644 --- a/mcp-server/src/tools/generate-totp.js +++ b/mcp-server/src/tools/generate-totp.ts @@ -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; + /** * 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} Tool result */ -export async function generateTotp(args) { +export async function generateTotp(args: GenerateTotpInput): Promise { 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, diff --git a/mcp-server/src/tools/save-deliverable.js b/mcp-server/src/tools/save-deliverable.ts similarity index 86% rename from mcp-server/src/tools/save-deliverable.js rename to mcp-server/src/tools/save-deliverable.ts index 0971113..6e35013 100644 --- a/mcp-server/src/tools/save-deliverable.js +++ b/mcp-server/src/tools/save-deliverable.ts @@ -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; + /** * 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} Tool result */ -export async function saveDeliverable(args) { +export async function saveDeliverable(args: SaveDeliverableInput): Promise { 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, diff --git a/mcp-server/src/types/deliverables.js b/mcp-server/src/types/deliverables.ts similarity index 56% rename from mcp-server/src/types/deliverables.js rename to mcp-server/src/types/deliverables.ts index 1416e35..37c2b16 100644 --- a/mcp-server/src/types/deliverables.js +++ b/mcp-server/src/types/deliverables.ts @@ -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.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} vulnerabilities - Array of vulnerability objects + * Vulnerability queue structure */ +export interface VulnerabilityQueue { + vulnerabilities: VulnerabilityItem[]; +} + +export interface VulnerabilityItem { + [key: string]: unknown; +} diff --git a/mcp-server/src/types/index.js b/mcp-server/src/types/index.ts similarity index 100% rename from mcp-server/src/types/index.js rename to mcp-server/src/types/index.ts diff --git a/mcp-server/src/types/tool-responses.js b/mcp-server/src/types/tool-responses.js deleted file mode 100644 index 892ab99..0000000 --- a/mcp-server/src/types/tool-responses.js +++ /dev/null @@ -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} [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', - }; -} diff --git a/mcp-server/src/types/tool-responses.ts b/mcp-server/src/types/tool-responses.ts new file mode 100644 index 0000000..960d18f --- /dev/null +++ b/mcp-server/src/types/tool-responses.ts @@ -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; +} + +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', + }; +} diff --git a/mcp-server/src/utils/error-formatter.js b/mcp-server/src/utils/error-formatter.ts similarity index 50% rename from mcp-server/src/utils/error-formatter.js rename to mcp-server/src/utils/error-formatter.ts index 79a78a1..5669abe 100644 --- a/mcp-server/src/utils/error-formatter.js +++ b/mcp-server/src/utils/error-formatter.ts @@ -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} [context] - */ +import type { ErrorResponse } from '../types/tool-responses.js'; /** * Create a validation error response - * - * @param {string} message - * @param {boolean} [retryable=true] - * @param {Record} [context] - * @returns {ErrorResponse} */ -export function createValidationError(message, retryable = true, context) { +export function createValidationError( + message: string, + retryable: boolean = true, + context?: Record +): 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} [context] - * @returns {ErrorResponse} */ -export function createCryptoError(message, retryable = false, context) { +export function createCryptoError( + message: string, + retryable: boolean = false, + context?: Record +): 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} [context] - * @returns {ErrorResponse} */ -export function createGenericError(error, retryable = false, context) { +export function createGenericError( + error: unknown, + retryable: boolean = false, + context?: Record +): 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 }), }; } diff --git a/mcp-server/src/utils/file-operations.js b/mcp-server/src/utils/file-operations.ts similarity index 81% rename from mcp-server/src/utils/file-operations.js rename to mcp-server/src/utils/file-operations.ts index f3071a0..a10e438 100644 --- a/mcp-server/src/utils/file-operations.js +++ b/mcp-server/src/utils/file-operations.ts @@ -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 } diff --git a/mcp-server/src/validation/queue-validator.js b/mcp-server/src/validation/queue-validator.ts similarity index 60% rename from mcp-server/src/validation/queue-validator.js rename to mcp-server/src/validation/queue-validator.ts index d92541b..c043934 100644 --- a/mcp-server/src/validation/queue-validator.js +++ b/mcp-server/src/validation/queue-validator.ts @@ -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; // 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 { diff --git a/mcp-server/src/validation/totp-validator.js b/mcp-server/src/validation/totp-validator.ts similarity index 83% rename from mcp-server/src/validation/totp-validator.js rename to mcp-server/src/validation/totp-validator.ts index 9a51b9c..a5f7f74 100644 --- a/mcp-server/src/validation/totp-validator.js +++ b/mcp-server/src/validation/totp-validator.ts @@ -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'); } diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 0000000..5ef6c93 --- /dev/null +++ b/mcp-server/tsconfig.json @@ -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"] +} diff --git a/package-lock.json b/package-lock.json index 59ee289..412c0b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 80b95f5..c01d110 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/ai/claude-executor.js b/src/ai/claude-executor.ts similarity index 54% rename from src/ai/claude-executor.js rename to src/ai/claude-executor.ts index 0b347ee..c8fa5f2 100644 --- a/src/ai/claude-executor.js +++ b/src/ai/claude-executor.ts @@ -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; +} + +type McpServer = ReturnType | 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 { 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 { 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 = { '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 = 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 }; + 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 { 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 { try { const result = await $`cd ${sourceDir} && git rev-parse HEAD`; return result.stdout.trim(); - } catch (error) { + } catch { return null; } -} \ No newline at end of file +} diff --git a/src/audit/audit-session.js b/src/audit/audit-session.ts similarity index 65% rename from src/audit/audit-session.js rename to src/audit/audit-session.ts index 840c342..0eb907e 100644 --- a/src/audit/audit-session.js +++ b/src/audit/audit-session.ts @@ -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} */ - async initialize() { + async initialize(): Promise { if (this.initialized) { return; // Already initialized } @@ -72,10 +75,8 @@ export class AuditSession { /** * Ensure initialized (helper for lazy initialization) - * @private - * @returns {Promise} */ - async ensureInitialized() { + private async ensureInitialized(): Promise { 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} */ - async startAgent(agentName, promptContent, attemptNumber = 1) { + async startAgent( + agentName: string, + promptContent: string, + attemptNumber: number = 1 + ): Promise { 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} */ - async logEvent(eventType, eventData) { + async logEvent(eventType: string, eventData: unknown): Promise { 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} */ - async endAgent(agentName, result) { + async endAgent(agentName: string, result: AgentEndResult): Promise { // 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} */ - async markMultipleRolledBack(agentNames) { + async markMultipleRolledBack(agentNames: string[]): Promise { 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} */ - async updateSessionStatus(status) { + async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed'): Promise { await this.ensureInitialized(); const unlock = await sessionMutex.lock(this.sessionId); @@ -203,9 +187,8 @@ export class AuditSession { /** * Get current metrics (read-only) - * @returns {Promise} Current metrics */ - async getMetrics() { + async getMetrics(): Promise { await this.ensureInitialized(); return this.metricsTracker.getMetrics(); } diff --git a/src/audit/index.js b/src/audit/index.ts similarity index 100% rename from src/audit/index.js rename to src/audit/index.ts diff --git a/src/audit/logger.js b/src/audit/logger.ts similarity index 71% rename from src/audit/logger.js rename to src/audit/logger.ts index ed22ecc..281563a 100644 --- a/src/audit/logger.js +++ b/src/audit/logger.ts @@ -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} */ - async initialize() { + async initialize(): Promise { 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} */ - async writeHeader() { + private async writeHeader(): Promise { 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} */ - writeRaw(text) { + private writeRaw(text: string): Promise { 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} */ - async logEvent(eventType, eventData) { - const event = { + async logEvent(eventType: string, eventData: unknown): Promise { + 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} */ - async close() { + async close(): Promise { 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} */ - static async savePrompt(sessionMetadata, agentName, promptContent) { + static async savePrompt( + sessionMetadata: SessionMetadata, + agentName: string, + promptContent: string + ): Promise { const promptPath = generatePromptPath(sessionMetadata, agentName); // Create header with metadata @@ -167,7 +168,7 @@ export class AgentLogger { `**Saved:** ${formatTimestamp()}`, ``, `---`, - `` + ``, ].join('\n'); const fullContent = header + promptContent; diff --git a/src/audit/metrics-tracker.js b/src/audit/metrics-tracker.ts similarity index 56% rename from src/audit/metrics-tracker.js rename to src/audit/metrics-tracker.ts index d425df1..5afa6c8 100644 --- a/src/audit/metrics-tracker.js +++ b/src/audit/metrics-tracker.ts @@ -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; + agents: Record; + }; +} + +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 = 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} */ - async initialize() { + async initialize(): Promise { // 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(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} */ - async endAgent(agentName, result) { + async endAgent(agentName: string, result: AgentEndResult): Promise { + 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} */ - async markRolledBack(agentName) { - if (!this.data.metrics.agents[agentName]) { + async markRolledBack(agentName: string): Promise { + 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} */ - async markMultipleRolledBack(agentNames) { + async markMultipleRolledBack(agentNames: string[]): Promise { + 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} */ - async updateSessionStatus(status) { + async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed'): Promise { + 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 { + const phases: Record = { 'pre-recon': [], - 'recon': [], + recon: [], 'vulnerability-analysis': [], - 'exploitation': [], - 'reporting': [] + exploitation: [], + reporting: [], }; // Map agents to phases - const agentPhaseMap = { + const agentPhaseMap: Record = { '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 = {}; + 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} */ - async save() { + private async save(): Promise { + if (!this.data) return; await atomicWrite(this.sessionJsonPath, this.data); } /** * Reload metrics from disk - * @returns {Promise} */ - async reload() { - this.data = await readJson(this.sessionJsonPath); + async reload(): Promise { + this.data = await readJson(this.sessionJsonPath); } } diff --git a/src/audit/utils.js b/src/audit/utils.ts similarity index 58% rename from src/audit/utils.js rename to src/audit/utils.ts index ea727bb..ffc2c78 100644 --- a/src/audit/utils.js +++ b/src/audit/utils.ts @@ -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} */ -export async function ensureDirectory(dirPath) { +export async function ensureDirectory(dirPath: string): Promise { 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} */ -export async function atomicWrite(filePath, data) { +export async function atomicWrite(filePath: string, data: object | string): Promise { 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} Parsed JSON data */ -export async function readJson(filePath) { +export async function readJson(filePath: string): Promise { 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} True if file exists */ -export async function fileExists(filePath) { +export async function fileExists(filePath: string): Promise { 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} */ -export async function initializeAuditStructure(sessionMetadata) { +export async function initializeAuditStructure(sessionMetadata: SessionMetadata): Promise { const auditPath = generateAuditPath(sessionMetadata); const agentsPath = path.join(auditPath, 'agents'); const promptsPath = path.join(auditPath, 'prompts'); diff --git a/src/checkpoint-manager.js b/src/checkpoint-manager.ts similarity index 63% rename from src/checkpoint-manager.js rename to src/checkpoint-manager.ts index 5a99e12..d0f13f6 100644 --- a/src/checkpoint-manager.js +++ b/src/checkpoint-manager.ts @@ -4,8 +4,8 @@ // it under the terms of the GNU Affero General Public License version 3 // as published by the Free Software Foundation. -import { fs, path, $ } from 'zx'; -import chalk from 'chalk'; +import { fs, path } from 'zx'; +import chalk, { type ChalkInstance } from 'chalk'; import { PentestError } from './error-handling.js'; import { parseConfig, distributeConfig } from './config-parser.js'; import { executeGitCommandWithRetry } from './utils/git-manager.js'; @@ -13,7 +13,6 @@ import { formatDuration } from './audit/utils.js'; import { AGENTS, PHASES, - selectSession, validateAgent, validateAgentRange, validatePhase, @@ -23,11 +22,79 @@ import { markAgentFailed, getSessionStatus, rollbackToAgent, - updateSession + getSession } from './session-manager.js'; +import type { Session, AgentDefinition } from './session-manager.js'; +import type { AgentName, PhaseName, PromptName } from './types/index.js'; +import type { DistributedConfig } from './types/config.js'; +import type { SessionMetadata } from './audit/utils.js'; + +// Types for callback functions +type RunClaudePromptWithRetry = ( + prompt: string, + sourceDir: string, + allowedTools: string, + context: string, + description: string, + agentName: string | null, + colorFn: ChalkInstance, + sessionMetadata: SessionMetadata | null +) => Promise; + +type LoadPrompt = ( + promptName: string, + variables: { webUrl: string; repoPath: string; sourceDir?: string }, + config: DistributedConfig | null, + pipelineTestingMode: boolean +) => Promise; + +export interface AgentResult { + success: boolean; + duration: number; + cost?: number; + partialCost?: number; + error?: string; + retryable?: boolean; + logFile?: string; +} + +interface ValidationData { + shouldExploit: boolean; + vulnerabilityCount: number; +} + +interface SingleAgentResult { + success: boolean; + agentName: string; + result?: AgentResult; + validation?: ValidationData | null; + timing?: number | null; + cost?: number | null; + checkpoint?: string; + completedAt?: string; + attempts?: number; + logFile?: string; + error?: { + message: string; + type: string; + retryable: boolean; + originalError?: Error; + }; + failedAt?: string; + context?: { + targetRepo: string; + promptName: PromptName; + sessionId: string; + }; +} + +interface ParallelResult { + completed: AgentName[]; + failed: Array<{ agent: AgentName; error: string }>; +} // Check if target repository exists and is accessible -const validateTargetRepo = async (targetRepo) => { +const validateTargetRepo = async (targetRepo: string): Promise => { if (!targetRepo || !await fs.pathExists(targetRepo)) { throw new PentestError( `Target repository '${targetRepo}' not found or not accessible`, @@ -36,7 +103,7 @@ const validateTargetRepo = async (targetRepo) => { { targetRepo } ); } - + // Check if it's a git repository const gitDir = path.join(targetRepo, '.git'); if (!await fs.pathExists(gitDir)) { @@ -47,87 +114,132 @@ const validateTargetRepo = async (targetRepo) => { { targetRepo } ); } - + return true; }; // Get git commit hash for checkpoint -export const getGitCommitHash = async (targetRepo) => { +export const getGitCommitHash = async (targetRepo: string): Promise => { try { const result = await executeGitCommandWithRetry(['git', 'rev-parse', 'HEAD'], targetRepo, 'getting commit hash'); return result.stdout.trim(); } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); throw new PentestError( - `Failed to get git commit hash: ${error.message}`, - 'git', + `Failed to get git commit hash: ${errMsg}`, + 'validation', false, - { targetRepo, originalError: error.message } + { targetRepo, originalError: errMsg } ); } }; // Rollback git workspace to specific commit -const rollbackGitToCommit = async (targetRepo, commitHash) => { +const rollbackGitToCommit = async (targetRepo: string, commitHash: string): Promise => { try { await executeGitCommandWithRetry(['git', 'reset', '--hard', commitHash], targetRepo, 'rollback to commit'); await executeGitCommandWithRetry(['git', 'clean', '-fd'], targetRepo, 'cleaning after rollback'); console.log(chalk.green(`โœ… Git workspace rolled back to commit ${commitHash.substring(0, 8)}`)); } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); throw new PentestError( - `Failed to rollback git workspace: ${error.message}`, - 'git', + `Failed to rollback git workspace: ${errMsg}`, + 'validation', false, - { targetRepo, commitHash, originalError: error.message } + { targetRepo, commitHash, originalError: errMsg } ); } }; +// Helper function to get prompt name from agent name +const getPromptName = (agentName: AgentName): PromptName => { + const mappings: Record = { + 'pre-recon': 'pre-recon-code', + 'recon': 'recon', + 'injection-vuln': 'vuln-injection', + 'xss-vuln': 'vuln-xss', + 'auth-vuln': 'vuln-auth', + 'ssrf-vuln': 'vuln-ssrf', + 'authz-vuln': 'vuln-authz', + 'injection-exploit': 'exploit-injection', + 'xss-exploit': 'exploit-xss', + 'auth-exploit': 'exploit-auth', + 'ssrf-exploit': 'exploit-ssrf', + 'authz-exploit': 'exploit-authz', + 'report': 'report-executive' + }; + + return mappings[agentName] || agentName as PromptName; +}; + +// Get color function for agent +const getAgentColor = (agentName: AgentName): ChalkInstance => { + const colorMap: Partial> = { + 'injection-vuln': chalk.red, + 'injection-exploit': chalk.red, + 'xss-vuln': chalk.yellow, + 'xss-exploit': chalk.yellow, + 'auth-vuln': chalk.blue, + 'auth-exploit': chalk.blue, + 'ssrf-vuln': chalk.magenta, + 'ssrf-exploit': chalk.magenta, + 'authz-vuln': chalk.green, + 'authz-exploit': chalk.green + }; + return colorMap[agentName] || chalk.cyan; +}; + // Run a single agent with retry logic and checkpointing -const runSingleAgent = async (agentName, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, allowRerun = false, skipWorkspaceClean = false) => { +const runSingleAgent = async ( + agentName: AgentName, + session: Session, + pipelineTestingMode: boolean, + runClaudePromptWithRetry: RunClaudePromptWithRetry, + loadPrompt: LoadPrompt, + allowRerun: boolean = false, + skipWorkspaceClean: boolean = false +): Promise => { // Validate agent first const agent = validateAgent(agentName); console.log(chalk.cyan(`\n๐Ÿค– Running agent: ${agent.displayName}`)); - + // Reload session to get latest state (important for agent ranges) - const { getSession } = await import('./session-manager.js'); const freshSession = await getSession(session.id); if (!freshSession) { throw new PentestError(`Session ${session.id} not found`, 'validation', false); } - + // Use fresh session for all subsequent checks - session = freshSession; - + const currentSession = freshSession; + // Warn if session is completed - if (session.status === 'completed') { + if (currentSession.status === 'completed') { console.log(chalk.yellow('โš ๏ธ This session is already completed. Re-running will modify completed results.')); } - - // Block re-running completed agents unless explicitly allowed - use --rerun for explicit rollback and re-run - if (!allowRerun && session.completedAgents.includes(agentName)) { + + // Block re-running completed agents unless explicitly allowed + if (!allowRerun && currentSession.completedAgents.includes(agentName)) { throw new PentestError( `Agent '${agentName}' has already been completed. Use --rerun ${agentName} for explicit rollback and re-execution.`, 'validation', false, - { - agentName, + { + agentName, suggestion: `--rerun ${agentName}`, - completedAgents: session.completedAgents + completedAgents: currentSession.completedAgents } ); } - - const targetRepo = session.targetRepo; + + const targetRepo = currentSession.targetRepo; await validateTargetRepo(targetRepo); - + // Check prerequisites - checkPrerequisites(session, agentName); - - // Additional safety check: if this agent is not completed but we have uncommitted changes, - // it might be from a previous interrupted run. Clean the workspace to be safe. - // Skip workspace cleaning during parallel execution to avoid agents interfering with each other - if (!session.completedAgents.includes(agentName) && !allowRerun && !skipWorkspaceClean) { + checkPrerequisites(currentSession, agentName); + + // Clean workspace if needed + if (!currentSession.completedAgents.includes(agentName) && !allowRerun && !skipWorkspaceClean) { try { const status = await executeGitCommandWithRetry(['git', 'status', '--porcelain'], targetRepo, 'checking workspace status'); const hasUncommittedChanges = status.stdout.trim().length > 0; @@ -140,114 +252,97 @@ const runSingleAgent = async (agentName, session, pipelineTestingMode, runClaude console.log(chalk.green(` โœ… Workspace cleaned successfully`)); } } catch (error) { - console.log(chalk.yellow(` โš ๏ธ Could not check/clean workspace: ${error.message}`)); + const errMsg = error instanceof Error ? error.message : String(error); + console.log(chalk.yellow(` โš ๏ธ Could not check/clean workspace: ${errMsg}`)); } } - - // Create checkpoint before execution + + // Create variables for prompt const variables = { - webUrl: session.webUrl, - repoPath: session.repoPath, + webUrl: currentSession.webUrl, + repoPath: currentSession.repoPath, sourceDir: targetRepo }; - - // Handle relative config paths - prepend configs/ if needed - let configPath = null; - if (session.configFile) { - configPath = path.isAbsolute(session.configFile) || session.configFile.startsWith('configs/') - ? session.configFile - : path.join('configs', session.configFile); + + // Handle relative config paths + let configPath: string | null = null; + if (currentSession.configFile) { + configPath = path.isAbsolute(currentSession.configFile) || currentSession.configFile.startsWith('configs/') + ? currentSession.configFile + : path.join('configs', currentSession.configFile); } - + const config = configPath ? await parseConfig(configPath) : null; const distributedConfig = config ? distributeConfig(config) : null; - // Removed prompt snapshotting - using live prompts from repo - // Initialize variables that will be used in both try and catch blocks - let validationData = null; - let timingData = null; - let costData = null; + // Initialize variables for result + let validationData: ValidationData | null = null; + let timingData: number | null = null; + let costData: number | null = null; try { // Load and run the appropriate prompt - let promptName = getPromptName(agentName); + const promptName = getPromptName(agentName); const prompt = await loadPrompt(promptName, variables, distributedConfig, pipelineTestingMode); - - // Get color function for this agent - const getAgentColor = (agentName) => { - const colorMap = { - 'injection-vuln': chalk.red, - 'injection-exploit': chalk.red, - 'xss-vuln': chalk.yellow, - 'xss-exploit': chalk.yellow, - 'auth-vuln': chalk.blue, - 'auth-exploit': chalk.blue, - 'ssrf-vuln': chalk.magenta, - 'ssrf-exploit': chalk.magenta, - 'authz-vuln': chalk.green, - 'authz-exploit': chalk.green - }; - return colorMap[agentName] || chalk.cyan; - }; const result = await runClaudePromptWithRetry( prompt, targetRepo, '*', '', - AGENTS[agentName].displayName, - agentName, // Pass agent name for snapshot creation - getAgentColor(agentName), // Pass color function for this agent - { id: session.id, webUrl: session.webUrl, repoPath: session.repoPath } // Session metadata for audit logging + AGENTS[agentName]!.displayName, + agentName, + getAgentColor(agentName), + { id: currentSession.id, webUrl: currentSession.webUrl, repoPath: currentSession.repoPath } ); - + if (!result.success) { throw new PentestError( `Agent execution failed: ${result.error}`, - 'agent', + 'validation', result.retryable || false, { agentName, result } ); } - + // Get commit hash for checkpoint const commitHash = await getGitCommitHash(targetRepo); - - // Extract timing and cost data from result if available + + // Extract timing and cost data timingData = result.duration; costData = result.cost || 0; if (agentName.includes('-vuln')) { - // Extract vulnerability type from agent name (e.g., 'injection-vuln' -> 'injection') + // Validate vulnerability analysis results const vulnType = agentName.replace('-vuln', ''); try { const { safeValidateQueueAndDeliverable } = await import('./queue-validation.js'); - const validation = await safeValidateQueueAndDeliverable(vulnType, targetRepo); + const validation = await safeValidateQueueAndDeliverable(vulnType as 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz', targetRepo); - if (validation.success) { - // Log validation result (don't store - will be re-validated during exploitation phase) + if (validation.success && validation.data) { console.log(chalk.blue(`๐Ÿ“‹ Validation: ${validation.data.shouldExploit ? `Ready for exploitation (${validation.data.vulnerabilityCount} vulnerabilities)` : 'No vulnerabilities found'}`)); validationData = { shouldExploit: validation.data.shouldExploit, vulnerabilityCount: validation.data.vulnerabilityCount }; - } else { + } else if (validation.error) { console.log(chalk.yellow(`โš ๏ธ Validation failed: ${validation.error.message}`)); } } catch (validationError) { - console.log(chalk.yellow(`โš ๏ธ Could not validate ${vulnType}: ${validationError.message}`)); + const errMsg = validationError instanceof Error ? validationError.message : String(validationError); + console.log(chalk.yellow(`โš ๏ธ Could not validate ${vulnType}: ${errMsg}`)); } } - // Mark agent as completed (validation not stored - will be re-checked during exploitation) - await markAgentCompleted(session.id, agentName, commitHash); + // Mark agent as completed + await markAgentCompleted(currentSession.id, agentName, commitHash); // Only show completion message for sequential execution if (!skipWorkspaceClean) { console.log(chalk.green(`โœ… Agent '${agentName}' completed successfully`)); } - // Return immutable result object with enhanced metadata + // Return immutable result object return Object.freeze({ success: true, agentName, @@ -258,25 +353,27 @@ const runSingleAgent = async (agentName, session, pipelineTestingMode, runClaude checkpoint: commitHash, completedAt: new Date().toISOString() }); - + } catch (error) { // Mark agent as failed - await markAgentFailed(session.id, agentName); + await markAgentFailed(currentSession.id, agentName); + + const err = error as Error & { retryable?: boolean }; // Only show failure message for sequential execution if (!skipWorkspaceClean) { - console.log(chalk.red(`โŒ Agent '${agentName}' failed: ${error.message}`)); + console.log(chalk.red(`โŒ Agent '${agentName}' failed: ${err.message}`)); } - // Return immutable error object with enhanced context - const errorResult = Object.freeze({ + // Return immutable error object + const errorResult: SingleAgentResult = Object.freeze({ success: false, agentName, error: { - message: error.message, - type: error.constructor.name, - retryable: error.retryable || false, - originalError: error + message: err.message, + type: err.constructor.name, + retryable: err.retryable || false, + originalError: err }, validation: validationData, timing: timingData, @@ -284,19 +381,19 @@ const runSingleAgent = async (agentName, session, pipelineTestingMode, runClaude context: { targetRepo, promptName: getPromptName(agentName), - sessionId: session.id + sessionId: currentSession.id } }); - // Throw enhanced error with preserved context + // Throw enhanced error const enhancedError = new PentestError( - `Agent '${agentName}' execution failed: ${error.message}`, - 'agent', - error.retryable || false, + `Agent '${agentName}' execution failed: ${err.message}`, + 'validation', + err.retryable || false, { agentName, - sessionId: session.id, - originalError: error.message, + sessionId: currentSession.id, + originalError: err.message, errorResult } ); @@ -305,33 +402,14 @@ const runSingleAgent = async (agentName, session, pipelineTestingMode, runClaude } }; -// Run multiple agents in sequence -const runAgentRange = async (startAgent, endAgent, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt) => { - const agents = validateAgentRange(startAgent, endAgent); - - console.log(chalk.cyan(`\n๐Ÿ”„ Running agent range: ${startAgent} to ${endAgent} (${agents.length} agents)`)); - - for (const agent of agents) { - // Skip if already completed - if (session.completedAgents.includes(agent.name)) { - console.log(chalk.gray(`โญ๏ธ Agent '${agent.name}' already completed, skipping`)); - continue; - } - - try { - await runSingleAgent(agent.name, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt); - } catch (error) { - console.log(chalk.red(`โŒ Agent range execution stopped at '${agent.name}' due to failure`)); - throw error; - } - } - - console.log(chalk.green(`โœ… Agent range ${startAgent} to ${endAgent} completed successfully`)); -}; - // Run vulnerability agents in parallel -const runParallelVuln = async (session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt) => { - const vulnAgents = ['injection-vuln', 'xss-vuln', 'auth-vuln', 'ssrf-vuln', 'authz-vuln']; +const runParallelVuln = async ( + session: Session, + pipelineTestingMode: boolean, + runClaudePromptWithRetry: RunClaudePromptWithRetry, + loadPrompt: LoadPrompt +): Promise => { + const vulnAgents: AgentName[] = ['injection-vuln', 'xss-vuln', 'auth-vuln', 'ssrf-vuln', 'authz-vuln']; const activeAgents = vulnAgents.filter(agent => !session.completedAgents.includes(agent)); if (activeAgents.length === 0) { @@ -351,7 +429,7 @@ const runParallelVuln = async (session, pipelineTestingMode, runClaudePromptWith // Add 2-second stagger to prevent API overwhelm await new Promise(resolve => setTimeout(resolve, index * 2000)); - let lastError; + let lastError: Error | undefined; let attempts = 0; const maxAttempts = 3; @@ -359,9 +437,9 @@ const runParallelVuln = async (session, pipelineTestingMode, runClaudePromptWith attempts++; try { const result = await runSingleAgent(agentName, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, false, true); - return { agentName, ...result, attempts }; + return { ...result, attempts }; } catch (error) { - lastError = error; + lastError = error as Error; if (attempts < maxAttempts) { console.log(chalk.yellow(`โš ๏ธ ${agentName} failed attempt ${attempts}/${maxAttempts}, retrying...`)); await new Promise(resolve => setTimeout(resolve, 5000)); @@ -374,19 +452,17 @@ const runParallelVuln = async (session, pipelineTestingMode, runClaudePromptWith const totalDuration = Date.now() - startTime; - // Process and display results in a nice table + // Process and display results console.log(chalk.cyan('\n๐Ÿ“Š Vulnerability Analysis Results')); console.log(chalk.gray('โ”€'.repeat(80))); - - // Table header console.log(chalk.bold('Agent Status Vulns Attempt Duration Cost')); console.log(chalk.gray('โ”€'.repeat(80))); - const completed = []; - const failed = []; + const completed: AgentName[] = []; + const failed: Array<{ agent: AgentName; error: string }> = []; results.forEach((result, index) => { - const agentName = activeAgents[index]; + const agentName = activeAgents[index]!; const agentDisplay = agentName.padEnd(22); if (result.status === 'fulfilled') { @@ -402,22 +478,23 @@ const runParallelVuln = async (session, pipelineTestingMode, runClaudePromptWith `${data.attempts}/3 ${duration.padEnd(11)} ${cost}` ); - // Show log file path for detailed review if (data.logFile) { const relativePath = path.relative(process.cwd(), data.logFile); console.log(chalk.gray(` โ””โ”€ Detailed log: ${relativePath}`)); } } else { - const error = result.reason.error || result.reason; - failed.push({ agent: agentName, error: error.message }); + const reason = result.reason as { error?: Error; attempts?: number }; + const error = reason.error || result.reason; + const errMsg = error instanceof Error ? error.message : String(error); + failed.push({ agent: agentName, error: errMsg }); - const attempts = result.reason.attempts || 3; // Default to 3 if not available + const attempts = reason.attempts || 3; console.log( `${chalk.red(agentDisplay)} ${chalk.red('โœ— Failed ')} - ` + `${attempts}/3 - -` ); - console.log(chalk.gray(` โ””โ”€ ${error.message.substring(0, 60)}...`)); + console.log(chalk.gray(` โ””โ”€ ${errMsg.substring(0, 60)}...`)); } }); @@ -428,32 +505,36 @@ const runParallelVuln = async (session, pipelineTestingMode, runClaudePromptWith }; // Run exploitation agents in parallel -const runParallelExploit = async (session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt) => { - const exploitAgents = ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit']; +const runParallelExploit = async ( + session: Session, + pipelineTestingMode: boolean, + runClaudePromptWithRetry: RunClaudePromptWithRetry, + loadPrompt: LoadPrompt +): Promise => { + const exploitAgents: AgentName[] = ['injection-exploit', 'xss-exploit', 'auth-exploit', 'ssrf-exploit', 'authz-exploit']; - // Get fresh session data to ensure we have the latest vulnerability analysis results - // This prevents race conditions where parallel vuln agents haven't updated session state yet - const { getSession } = await import('./session-manager.js'); + // Get fresh session data const freshSession = await getSession(session.id); + if (!freshSession) { + throw new PentestError(`Session ${session.id} not found`, 'validation', false); + } // Load validation module const { safeValidateQueueAndDeliverable } = await import('./queue-validation.js'); - // Only run exploit agents whose vuln counterparts completed successfully AND found vulnerabilities + // Check eligibility const eligibilityChecks = await Promise.all( exploitAgents.map(async (agentName) => { - const vulnAgentName = agentName.replace('-exploit', '-vuln'); + const vulnAgentName = agentName.replace('-exploit', '-vuln') as AgentName; - // Must have completed the vulnerability analysis if (!freshSession.completedAgents.includes(vulnAgentName)) { return { agentName, eligible: false }; } - // Check if vulnerabilities were found by validating the queue file - const vulnType = vulnAgentName.replace('-vuln', ''); // "injection-vuln" -> "injection" + const vulnType = vulnAgentName.replace('-vuln', '') as 'injection' | 'xss' | 'auth' | 'ssrf' | 'authz'; const validation = await safeValidateQueueAndDeliverable(vulnType, freshSession.targetRepo); - if (!validation.success || !validation.data.shouldExploit) { + if (!validation.success || !validation.data?.shouldExploit) { console.log(chalk.gray(`โญ๏ธ Skipping ${agentName} (no vulnerabilities found in ${vulnAgentName})`)); return { agentName, eligible: false }; } @@ -484,13 +565,11 @@ const runParallelExploit = async (session, pipelineTestingMode, runClaudePromptW const startTime = Date.now(); - // Collect all results without logging individual completions const results = await Promise.allSettled( activeAgents.map(async (agentName, index) => { - // Add 2-second stagger to prevent API overwhelm await new Promise(resolve => setTimeout(resolve, index * 2000)); - let lastError; + let lastError: Error | undefined; let attempts = 0; const maxAttempts = 3; @@ -498,9 +577,9 @@ const runParallelExploit = async (session, pipelineTestingMode, runClaudePromptW attempts++; try { const result = await runSingleAgent(agentName, freshSession, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, false, true); - return { agentName, ...result, attempts }; + return { ...result, attempts }; } catch (error) { - lastError = error; + lastError = error as Error; if (attempts < maxAttempts) { console.log(chalk.yellow(`โš ๏ธ ${agentName} failed attempt ${attempts}/${maxAttempts}, retrying...`)); await new Promise(resolve => setTimeout(resolve, 5000)); @@ -513,26 +592,23 @@ const runParallelExploit = async (session, pipelineTestingMode, runClaudePromptW const totalDuration = Date.now() - startTime; - // Process and display results in a nice table console.log(chalk.cyan('\n๐ŸŽฏ Exploitation Results')); console.log(chalk.gray('โ”€'.repeat(80))); - - // Table header console.log(chalk.bold('Agent Status Result Attempt Duration Cost')); console.log(chalk.gray('โ”€'.repeat(80))); - const completed = []; - const failed = []; + const completed: AgentName[] = []; + const failed: Array<{ agent: AgentName; error: string }> = []; results.forEach((result, index) => { - const agentName = activeAgents[index]; + const agentName = activeAgents[index]!; const agentDisplay = agentName.padEnd(22); if (result.status === 'fulfilled') { const data = result.value; completed.push(agentName); - const exploitResult = 'Success'; // Could be enhanced to show actual exploitation result + const exploitResult = 'Success'; const duration = formatDuration(data.timing || 0); const cost = `$${(data.cost || 0).toFixed(4)}`; @@ -541,22 +617,23 @@ const runParallelExploit = async (session, pipelineTestingMode, runClaudePromptW `${data.attempts}/3 ${duration.padEnd(11)} ${cost}` ); - // Show log file path for detailed review if (data.logFile) { const relativePath = path.relative(process.cwd(), data.logFile); console.log(chalk.gray(` โ””โ”€ Detailed log: ${relativePath}`)); } } else { - const error = result.reason.error || result.reason; - failed.push({ agent: agentName, error: error.message }); + const reason = result.reason as { error?: Error; attempts?: number }; + const error = reason.error || result.reason; + const errMsg = error instanceof Error ? error.message : String(error); + failed.push({ agent: agentName, error: errMsg }); - const attempts = result.reason.attempts || 3; // Default to 3 if not available + const attempts = reason.attempts || 3; console.log( `${chalk.red(agentDisplay)} ${chalk.red('โœ— Failed ')} - ` + `${attempts}/3 - -` ); - console.log(chalk.gray(` โ””โ”€ ${error.message.substring(0, 60)}...`)); + console.log(chalk.gray(` โ””โ”€ ${errMsg.substring(0, 60)}...`)); } }); @@ -567,10 +644,15 @@ const runParallelExploit = async (session, pipelineTestingMode, runClaudePromptW }; // Run all agents in a phase -export const runPhase = async (phaseName, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt) => { +export const runPhase = async ( + phaseName: string, + session: Session, + pipelineTestingMode: boolean, + runClaudePromptWithRetry: RunClaudePromptWithRetry, + loadPrompt: LoadPrompt +): Promise => { console.log(chalk.cyan(`\n๐Ÿ“‹ Running phase: ${phaseName} (parallel execution)`)); - // Use parallel execution for both vulnerability-analysis and exploitation phases if (phaseName === 'vulnerability-analysis') { console.log(chalk.cyan('๐Ÿš€ Using parallel execution for 5x faster vulnerability analysis')); const results = await runParallelVuln(session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt); @@ -601,10 +683,10 @@ export const runPhase = async (phaseName, session, pipelineTestingMode, runClaud return; } - // For other phases (pre-reconnaissance, reconnaissance, reporting), run the single agent + // For other phases, run single agent const agents = validatePhase(phaseName); if (agents.length === 1) { - const agent = agents[0]; + const agent = agents[0]!; if (session.completedAgents.includes(agent.name)) { console.log(chalk.gray(`โญ๏ธ Agent '${agent.name}' already completed, skipping`)); return; @@ -618,13 +700,14 @@ export const runPhase = async (phaseName, session, pipelineTestingMode, runClaud }; // Rollback to specific agent checkpoint -export const rollbackTo = async (targetAgent, session) => { +export const rollbackTo = async (targetAgent: string, session: Session): Promise => { console.log(chalk.yellow(`๐Ÿ”„ Rolling back to agent: ${targetAgent}`)); - + await validateTargetRepo(session.targetRepo); validateAgent(targetAgent); - - if (!session.checkpoints[targetAgent]) { + + const agentName = targetAgent as AgentName; + if (!session.checkpoints[agentName]) { throw new PentestError( `No checkpoint found for agent '${targetAgent}' in session history`, 'validation', @@ -632,201 +715,192 @@ export const rollbackTo = async (targetAgent, session) => { { targetAgent, availableCheckpoints: Object.keys(session.checkpoints) } ); } - - const commitHash = session.checkpoints[targetAgent]; - // Rollback git workspace + const commitHash = session.checkpoints[agentName]!; + await rollbackGitToCommit(session.targetRepo, commitHash); - - // Update session state (removes agents from completedAgents) await rollbackToAgent(session.id, targetAgent); - // Mark rolled-back agents in audit system (for forensic trail) + // Mark rolled-back agents in audit system try { const { AuditSession } = await import('./audit/index.js'); - const auditSession = new AuditSession(session); + const sessionMetadata: SessionMetadata = { + id: session.id, + webUrl: session.webUrl, + repoPath: session.repoPath + }; + const auditSession = new AuditSession(sessionMetadata); await auditSession.initialize(); - // Find agents that were rolled back (agents after targetAgent) - const targetOrder = AGENTS[targetAgent].order; + const targetOrder = AGENTS[agentName]!.order; const rolledBackAgents = Object.values(AGENTS) .filter(agent => agent.order > targetOrder) .map(agent => agent.name); - // Mark them as rolled-back in audit system if (rolledBackAgents.length > 0) { await auditSession.markMultipleRolledBack(rolledBackAgents); console.log(chalk.gray(` Marked ${rolledBackAgents.length} agents as rolled-back in audit logs`)); } } catch (error) { - // Non-critical: rollback succeeded even if audit update failed - console.log(chalk.yellow(` โš ๏ธ Failed to update audit logs: ${error.message}`)); + const errMsg = error instanceof Error ? error.message : String(error); + console.log(chalk.yellow(` โš ๏ธ Failed to update audit logs: ${errMsg}`)); } console.log(chalk.green(`โœ… Successfully rolled back to agent '${targetAgent}'`)); }; -// Rerun specific agent (rollback to previous + run current) -export const rerunAgent = async (agentName, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt) => { +// Rerun specific agent +export const rerunAgent = async ( + agentName: string, + session: Session, + pipelineTestingMode: boolean, + runClaudePromptWithRetry: RunClaudePromptWithRetry, + loadPrompt: LoadPrompt +): Promise => { console.log(chalk.cyan(`๐Ÿ” Rerunning agent: ${agentName}`)); - + const agent = validateAgent(agentName); - - // Find previous agent checkpoint or initial state - let rollbackTarget = null; + + // Find previous agent checkpoint + let rollbackTarget: AgentName | null = null; if (agent.prerequisites.length > 0) { - // Find the last completed prerequisite - const completedPrereqs = agent.prerequisites.filter(prereq => + const completedPrereqs = agent.prerequisites.filter(prereq => session.completedAgents.includes(prereq) ); if (completedPrereqs.length > 0) { - // Get the prerequisite with highest order - rollbackTarget = completedPrereqs.reduce((latest, current) => - AGENTS[current].order > AGENTS[latest].order ? current : latest + rollbackTarget = completedPrereqs.reduce((latest, current) => + AGENTS[current]!.order > AGENTS[latest]!.order ? current : latest ); } } - + if (rollbackTarget) { console.log(chalk.blue(`๐Ÿ“ Rolling back to prerequisite: ${rollbackTarget}`)); await rollbackTo(rollbackTarget, session); } else if (agent.name === 'pre-recon') { - // Special case: rollback to initial clone console.log(chalk.blue(`๐Ÿ“ Rolling back to initial repository state`)); try { const initialCommit = await executeGitCommandWithRetry(['git', 'log', '--reverse', '--format=%H'], session.targetRepo, 'finding initial commit'); const firstCommit = initialCommit.stdout.trim().split('\n')[0]; - await rollbackGitToCommit(session.targetRepo, firstCommit); + if (firstCommit) { + await rollbackGitToCommit(session.targetRepo, firstCommit); + } } catch (error) { - console.log(chalk.yellow(`โš ๏ธ Could not find initial commit, using HEAD: ${error.message}`)); + const errMsg = error instanceof Error ? error.message : String(error); + console.log(chalk.yellow(`โš ๏ธ Could not find initial commit, using HEAD: ${errMsg}`)); } } - - // Run the target agent (allow rerun since we've explicitly rolled back) - await runSingleAgent(agentName, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, true); - + + await runSingleAgent(agent.name, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt, true); + console.log(chalk.green(`โœ… Agent '${agentName}' rerun completed successfully`)); }; -// Run all remaining agents to completion -export const runAll = async (session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt) => { - // Get all agents in order - const allAgentNames = Object.keys(AGENTS); - +// Run all remaining agents +export const runAll = async ( + session: Session, + pipelineTestingMode: boolean, + runClaudePromptWithRetry: RunClaudePromptWithRetry, + loadPrompt: LoadPrompt +): Promise => { + const allAgentNames = Object.keys(AGENTS) as AgentName[]; + console.log(chalk.cyan(`\n๐Ÿš€ Running all remaining agents to completion`)); console.log(chalk.gray(`Current progress: ${session.completedAgents.length}/${allAgentNames.length} agents completed`)); - - // Find remaining agents (not yet completed) - const remainingAgents = allAgentNames.filter(agentName => + + const remainingAgents = allAgentNames.filter(agentName => !session.completedAgents.includes(agentName) ); - + if (remainingAgents.length === 0) { console.log(chalk.green('โœ… All agents already completed!')); return; } - + console.log(chalk.blue(`๐Ÿ“‹ Remaining agents: ${remainingAgents.join(', ')}`)); console.log(); - - // Run each remaining agent in sequence + for (const agentName of remainingAgents) { await runSingleAgent(agentName, session, pipelineTestingMode, runClaudePromptWithRetry, loadPrompt); } - + console.log(chalk.green(`\n๐ŸŽ‰ All agents completed successfully! Session marked as completed.`)); }; +// Helper for time ago calculation +const getTimeAgo = (timestamp: string): string => { + const now = new Date(); + const past = new Date(timestamp); + const diffMs = now.getTime() - past.getTime(); + + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 60) { + return `${diffMins}m ago`; + } else if (diffHours < 24) { + return `${diffHours}h ago`; + } else { + return `${diffDays}d ago`; + } +}; + // Display session status -export const displayStatus = async (session) => { +export const displayStatus = async (session: Session): Promise => { const status = getSessionStatus(session); const timeAgo = getTimeAgo(session.lastActivity); - + console.log(chalk.cyan(`Session: ${new URL(session.webUrl).hostname} + ${path.basename(session.repoPath)}`)); console.log(chalk.gray(`Session ID: ${session.id}`)); console.log(chalk.gray(`Source Directory: ${session.targetRepo}`)); - - // Check if final deliverable exists and show its path + + // Check if final deliverable exists if (session.targetRepo) { const finalReportPath = path.join(session.targetRepo, 'deliverables', 'comprehensive_security_assessment_report.md'); try { if (await fs.pathExists(finalReportPath)) { console.log(chalk.gray(`Final Deliverable Available: ${finalReportPath}`)); } - } catch (error) { - // Silently ignore if we can't check the file + } catch { + // Silently ignore } } - + const statusColor = status.status === 'completed' ? chalk.green : status.status === 'failed' ? chalk.red : chalk.blue; console.log(statusColor(`Status: ${status.status} (${status.completedCount}/${status.totalAgents} agents completed)`)); console.log(chalk.gray(`Last Activity: ${timeAgo}`)); - + if (session.configFile) { console.log(chalk.gray(`Config: ${session.configFile}`)); } - - // Display cost and timing breakdown if available - if (session.costBreakdown || session.timingBreakdown) { - console.log(); // Empty line before metrics - - if (session.timingBreakdown) { - console.log(chalk.blue('โฑ๏ธ Timing Breakdown:')); - console.log(chalk.gray(` Total Execution: ${formatDuration(session.timingBreakdown.total || 0)}`)); - - if (session.timingBreakdown.phases) { - Object.entries(session.timingBreakdown.phases).forEach(([phase, duration]) => { - console.log(chalk.gray(` ${phase}: ${formatDuration(duration)}`)); - }); - } - - if (session.timingBreakdown.agents) { - console.log(chalk.gray(' Per Agent:')); - Object.entries(session.timingBreakdown.agents).forEach(([agent, duration]) => { - console.log(chalk.gray(` ${agent}: ${formatDuration(duration)}`)); - }); - } - } - - if (session.costBreakdown) { - console.log(chalk.blue('๐Ÿ’ฐ Cost Breakdown:')); - console.log(chalk.gray(` Total Cost: $${(session.costBreakdown.total || 0).toFixed(4)}`)); - - if (session.costBreakdown.agents) { - console.log(chalk.gray(' Per Agent:')); - Object.entries(session.costBreakdown.agents).forEach(([agent, cost]) => { - console.log(chalk.gray(` ${agent}: $${cost.toFixed(4)}`)); - }); - } - } - } - - console.log(); // Empty line - + + console.log(); + // Display agent status const agentList = Object.values(AGENTS).sort((a, b) => a.order - b.order); - + for (const agent of agentList) { - let statusIcon, statusText, statusColor; - + let statusIcon: string, statusText: string, statusColorFn: ChalkInstance; + if (session.completedAgents.includes(agent.name)) { statusIcon = 'โœ…'; - statusText = `completed ${getTimeAgoForAgent(session, agent.name)}`; - statusColor = chalk.green; + statusText = `completed ${getTimeAgo(session.lastActivity)}`; + statusColorFn = chalk.green; } else if (session.failedAgents.includes(agent.name)) { statusIcon = 'โŒ'; - statusText = `failed ${getTimeAgoForAgent(session, agent.name)}`; - statusColor = chalk.red; + statusText = `failed ${getTimeAgo(session.lastActivity)}`; + statusColorFn = chalk.red; } else { statusIcon = 'โธ๏ธ'; statusText = 'pending'; - statusColor = chalk.gray; + statusColorFn = chalk.gray; } - + const displayName = agent.name.replace(/-/g, ' '); - console.log(`${statusIcon} ${statusColor(displayName.padEnd(20))} (${statusText})`); + console.log(`${statusIcon} ${statusColorFn(displayName.padEnd(20))} (${statusText})`); } - + // Show next action const nextAgent = getNextAgent(session); if (nextAgent) { @@ -840,70 +914,22 @@ export const displayStatus = async (session) => { }; // List all available agents -export const listAgents = () => { +export const listAgents = (): void => { console.log(chalk.cyan('Available Agents:')); - - const phaseNames = Object.keys(PHASES); - + + const phaseNames = Object.keys(PHASES) as PhaseName[]; + phaseNames.forEach((phaseName, phaseIndex) => { const phaseAgents = PHASES[phaseName]; - const phaseDisplayName = phaseName.split('-').map(word => + const phaseDisplayName = phaseName.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1) ).join(' '); - + console.log(chalk.yellow(`\nPhase ${phaseIndex + 1} - ${phaseDisplayName}:`)); - + phaseAgents.forEach(agentName => { - const agent = AGENTS[agentName]; + const agent = AGENTS[agentName]!; console.log(chalk.white(` ${agent.name.padEnd(18)} ${agent.displayName}`)); }); }); }; - -// Helper function to get prompt name from agent name -const getPromptName = (agentName) => { - const mappings = { - 'pre-recon': 'pre-recon-code', - 'recon': 'recon', - 'injection-vuln': 'vuln-injection', - 'xss-vuln': 'vuln-xss', - 'auth-vuln': 'vuln-auth', - 'ssrf-vuln': 'vuln-ssrf', - 'authz-vuln': 'vuln-authz', - 'injection-exploit': 'exploit-injection', - 'xss-exploit': 'exploit-xss', - 'auth-exploit': 'exploit-auth', - 'ssrf-exploit': 'exploit-ssrf', - 'authz-exploit': 'exploit-authz', - 'report': 'report-executive' - }; - - return mappings[agentName] || agentName; -}; - -// Helper function to get time ago for specific agent -const getTimeAgoForAgent = (session, agentName) => { - // This would need to be implemented based on session checkpoint timestamps - // For now, just return relative to last activity - return getTimeAgo(session.lastActivity); -}; - -// Helper function for time ago calculation -const getTimeAgo = (timestamp) => { - const now = new Date(); - const past = new Date(timestamp); - const diffMs = now - past; - - const diffMins = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - if (diffMins < 60) { - return `${diffMins}m ago`; - } else if (diffHours < 24) { - return `${diffHours}h ago`; - } else { - return `${diffDays}d ago`; - } -}; - diff --git a/src/cli/command-handler.js b/src/cli/command-handler.ts similarity index 69% rename from src/cli/command-handler.js rename to src/cli/command-handler.ts index cefeed4..f161b31 100644 --- a/src/cli/command-handler.js +++ b/src/cli/command-handler.ts @@ -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; + +type LoadPrompt = ( + promptName: string, + variables: { webUrl: string; repoPath: string }, + config: DistributedConfig | null, + pipelineTestingMode: boolean +) => Promise; // 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 { 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 ')); + console.log(chalk.gray('Usage: shannon --run-phase ')); 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} `)); + console.log(chalk.gray(`Usage: shannon ${command} `)); 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); } -} \ No newline at end of file +} diff --git a/src/cli/input-validator.js b/src/cli/input-validator.ts similarity index 78% rename from src/cli/input-validator.js rename to src/cli/input-validator.ts index ed9e6d4..dfcb597 100644 --- a/src/cli/input-validator.js +++ b/src/cli/input-validator.ts @@ -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 { 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}` }; } -} \ No newline at end of file +} diff --git a/src/cli/prompts.js b/src/cli/prompts.ts similarity index 58% rename from src/cli/prompts.js rename to src/cli/prompts.ts index 4523f97..4d02be9 100644 --- a/src/cli/prompts.js +++ b/src/cli/prompts.ts @@ -9,13 +9,11 @@ import { PentestError } from '../error-handling.js'; /** * Prompt user for yes/no confirmation - * @param {string} message - Question to display - * @returns {Promise} true if confirmed, false otherwise */ -export async function promptConfirmation(message) { +export async function promptConfirmation(message: string): Promise { 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} Selected item - * @throws {PentestError} If invalid selection */ -export async function promptSelection(message, items) { +export async function promptSelection(message: string, items: T[]): Promise { 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]!); } }); }); diff --git a/src/cli/ui.js b/src/cli/ui.js deleted file mode 100644 index b50123e..0000000 --- a/src/cli/ui.js +++ /dev/null @@ -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 [--config config.yaml] [--pipeline-testing]'); - console.log(' ./shannon.mjs --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 [--pipeline-testing]'); - console.log(' ./shannon.mjs --run-all [--pipeline-testing]'); - console.log(' ./shannon.mjs --rollback-to '); - console.log(' ./shannon.mjs --rerun [--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 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 # 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 }; \ No newline at end of file diff --git a/src/cli/ui.ts b/src/cli/ui.ts new file mode 100644 index 0000000..413ab54 --- /dev/null +++ b/src/cli/ui.ts @@ -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 [--config config.yaml] [--pipeline-testing]' + ); + console.log( + ' shannon --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 [--pipeline-testing]'); + console.log(' shannon --run-all [--pipeline-testing]'); + console.log(' shannon --rollback-to '); + console.log(' shannon --rerun [--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 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 # 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 }; diff --git a/src/config-parser.js b/src/config-parser.ts similarity index 57% rename from src/config-parser.js rename to src/config-parser.ts index c6ba56f..610fb3a 100644 --- a/src/config-parser.js +++ b/src/config-parser.ts @@ -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 => { 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(); 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 - diff --git a/src/constants.js b/src/constants.ts similarity index 71% rename from src/constants.js rename to src/constants.ts index cb2fe0f..4db8e9f 100644 --- a/src/constants.js +++ b/src/constants.ts @@ -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 => { 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 => { 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 = 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 = Object.freeze({ // Pre-reconnaissance agent - validates the code analysis deliverable created by the agent - 'pre-recon': async (sourceDir) => { + 'pre-recon': async (sourceDir: string): Promise => { 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 => { 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 => { + 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; - } -}); \ No newline at end of file + }, +}); diff --git a/src/error-handling.js b/src/error-handling.ts similarity index 53% rename from src/error-handling.js rename to src/error-handling.ts index b45e250..c2b5766 100644 --- a/src/error-handling.js +++ b/src/error-handling.ts @@ -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 => { 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 -}; \ No newline at end of file +}; diff --git a/src/phases/pre-recon.js b/src/phases/pre-recon.ts similarity index 65% rename from src/phases/pre-recon.js rename to src/phases/pre-recon.ts index 32ff71b..edbd395 100644 --- a/src/phases/pre-recon.js +++ b/src/phases/pre-recon.ts @@ -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 { 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 { console.log(chalk.blue(' โ†’ Launching Wave 1 operations in parallel...')); - const operations = []; + const operations: Promise[] = []; // 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 { 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[] = []; // 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 { 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 { 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 }; -} \ No newline at end of file +} diff --git a/src/phases/reporting.js b/src/phases/reporting.ts similarity index 81% rename from src/phases/reporting.js rename to src/phases/reporting.ts index 45b4d0b..0b5fc7c 100644 --- a/src/phases/reporting.js +++ b/src/phases/reporting.ts @@ -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 { + 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; -} \ No newline at end of file +} diff --git a/src/progress-indicator.js b/src/progress-indicator.ts similarity index 63% rename from src/progress-indicator.js rename to src/progress-indicator.ts index 6b0b7d4..d6700d4 100644 --- a/src/progress-indicator.js +++ b/src/progress-indicator.ts @@ -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 | 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}`)); } -} \ No newline at end of file +} diff --git a/src/prompts/prompt-manager.js b/src/prompts/prompt-manager.ts similarity index 80% rename from src/prompts/prompt-manager.js rename to src/prompts/prompt-manager.ts index 08f01f4..3ff47d5 100644 --- a/src/prompts/prompt-manager.js +++ b/src/prompts/prompt-manager.ts @@ -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 { 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(`([\\s\\S]*?)`, '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 { 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 { try { if (!template || typeof template !== 'string') { throw new PentestError( @@ -147,8 +164,8 @@ async function interpolateVariables(template, variables, config = null) { const cleanRulesSection = '\nNo specific rules or focus areas provided for this test.\n'; result = result.replace(/[\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 { 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; } -} \ No newline at end of file +} diff --git a/src/queue-validation.js b/src/queue-validation.ts similarity index 51% rename from src/queue-validation.js rename to src/queue-validation.ts index 530c79f..1f84a1e 100644 --- a/src/queue-validation.js +++ b/src/queue-validation.ts @@ -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; + +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; + +const pipe = + (...fns: PipeFunction[]) => + (x: any): Promise => + 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 => { + 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 => { + 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 => + (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 => { 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 }; } -}; \ No newline at end of file +}; diff --git a/src/session-manager.js b/src/session-manager.ts similarity index 71% rename from src/session-manager.js rename to src/session-manager.ts index 31d0c00..8433778 100644 --- a/src/session-manager.js +++ b/src/session-manager.ts @@ -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; + }; +} + +// 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; + createdAt: string; + lastActivity: string; +} + +// Session store interface +interface SessionStore { + sessions: Record; +} + +// 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> = 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> = 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 => { 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 => { 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 => { 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 => { // 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, 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 => { 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 +): Promise => { 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 => { const store = await loadSessions(); return Object.values(store.sessions); }; // Interactive session selection -export const selectSession = async () => { +export const selectSession = async (): Promise => { 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 => { // 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 = { + completedAgents: [...new Set([...session.completedAgents, agentName as AgentName])], failedAgents: session.failedAgents.filter(agent => agent !== agentName), checkpoints: { ...session.checkpoints, [agentName]: checkpointCommit - } + } as Record }; // 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 => { 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 = { + 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 => { 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 = { 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 }; // 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} Reconciliation report with added/removed/failed agents */ -export const reconcileSession = async (sessionId) => { +export const reconcileSession = async (sessionId: string): Promise => { 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 = { + completedAgents: freshSession.completedAgents.filter(agent => !toRemove.includes(agent)), + checkpoints: Object.fromEntries( + Object.entries(freshSession.checkpoints).filter(([agent]) => !toRemove.includes(agent as AgentName)) + ) as Record + }; - 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 => { 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 => { 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 } ); } -}; \ No newline at end of file +}; diff --git a/src/setup/environment.js b/src/setup/environment.ts similarity index 79% rename from src/setup/environment.js rename to src/setup/environment.ts index 0588af8..55f8bbe 100644 --- a/src/setup/environment.js +++ b/src/setup/environment.ts @@ -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 { 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, + }); } -} \ No newline at end of file +} diff --git a/shannon.mjs b/src/shannon.ts old mode 100755 new mode 100644 similarity index 72% rename from shannon.mjs rename to src/shannon.ts index b1acac7..b597012 --- a/shannon.mjs +++ b/src/shannon.ts @@ -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; +} + +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 { // 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 => { 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 [--config config.yaml]')); - console.log(chalk.gray('Help: ./shannon.mjs --help')); + console.log(chalk.gray('Usage: shannon [--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); -} \ No newline at end of file +} diff --git a/src/splash-screen.js b/src/splash-screen.ts similarity index 86% rename from src/splash-screen.js rename to src/splash-screen.ts index 57e2ffd..4fcca3e 100644 --- a/src/splash-screen.js +++ b/src/splash-screen.ts @@ -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 => { 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(''); } -}; \ No newline at end of file +}; diff --git a/src/tool-checker.js b/src/tool-checker.ts similarity index 65% rename from src/tool-checker.js rename to src/tool-checker.ts index 61d3c72..6340575 100644 --- a/src/tool-checker.js +++ b/src/tool-checker.ts @@ -7,13 +7,22 @@ import { $ } from 'zx'; import chalk from 'chalk'; +type ToolName = 'nmap' | 'subfinder' | 'whatweb' | 'schemathesis'; + +export type ToolAvailability = Record; + // Check availability of required tools -export const checkToolAvailability = async () => { - const tools = ['nmap', 'subfinder', 'whatweb', 'schemathesis']; - const availability = {}; - +export const checkToolAvailability = async (): Promise => { + 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 = { '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(''); } diff --git a/src/types/agents.ts b/src/types/agents.ts new file mode 100644 index 0000000..76e2a85 --- /dev/null +++ b/src/types/agents.ts @@ -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; + +export type AgentValidatorMap = Record; + +export type McpAgentMapping = Record; + +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'; diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..548a979 --- /dev/null +++ b/src/types/config.ts @@ -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; +} diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..42bf091 --- /dev/null +++ b/src/types/errors.ts @@ -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; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..6940ce7 --- /dev/null +++ b/src/types/index.ts @@ -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'; diff --git a/src/types/session.ts b/src/types/session.ts new file mode 100644 index 0000000..bbd25f1 --- /dev/null +++ b/src/types/session.ts @@ -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; + +export type PhaseDefinitions = Record; + +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; + checkpoints: Record; +} + +export interface SessionStore { + sessions: Record; +} + +export interface SessionSummary { + id: string; + targetUrl: string; + repoPath: string; + createdAt: string; + completedAgents: number; + totalAgents: number; +} diff --git a/src/utils/concurrency.js b/src/utils/concurrency.ts similarity index 78% rename from src/utils/concurrency.js rename to src/utils/concurrency.ts index c19ebd1..e10de45 100644 --- a/src/utils/concurrency.js +++ b/src/utils/concurrency.ts @@ -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> = new Map(); /** * Acquire lock for a session - * @param {string} sessionId - Session ID to lock - * @returns {Promise} Unlock function to release the lock */ - async lock(sessionId) { + async lock(sessionId: string): Promise { 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((r) => (resolve = r)); this.locks.set(sessionId, promise); // Return unlock function return () => { this.locks.delete(sessionId); - resolve(); + resolve!(); }; } } diff --git a/src/utils/git-manager.js b/src/utils/git-manager.js deleted file mode 100644 index 503b37d..0000000 --- a/src/utils/git-manager.js +++ /dev/null @@ -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 }; - } -}; \ No newline at end of file diff --git a/src/utils/git-manager.ts b/src/utils/git-manager.ts new file mode 100644 index 0000000..b48ad96 --- /dev/null +++ b/src/utils/git-manager.ts @@ -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 { + 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 => { + 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 => { + 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 => { + 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 => { + 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) }; + } +}; diff --git a/src/utils/metrics.js b/src/utils/metrics.ts similarity index 58% rename from src/utils/metrics.js rename to src/utils/metrics.ts index 91f4007..3b82774 100644 --- a/src/utils/metrics.js +++ b/src/utils/metrics.ts @@ -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))); -}; \ No newline at end of file +}; diff --git a/src/utils/output-formatter.js b/src/utils/output-formatter.ts similarity index 82% rename from src/utils/output-formatter.js rename to src/utils/output-formatter.ts index 9035a1e..1dabd43 100644 --- a/src/utils/output-formatter.js +++ b/src/utils/output-formatter.ts @@ -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 = { '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'); -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f56a026 --- /dev/null +++ b/tsconfig.json @@ -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" + ] +}