refactor: migrate to Turborepo + pnpm + Biome monorepo

Restructure into apps/worker, apps/cli, packages/mcp-server with
Turborepo task orchestration, pnpm workspaces, Biome linting/formatting,
and tsdown CLI bundling.

Key changes:
- src/ -> apps/worker/src/, cli/ -> apps/cli/, mcp-server/ -> packages/mcp-server/
- prompts/ and configs/ moved into apps/worker/
- npm replaced with pnpm, package-lock.json replaced with pnpm-lock.yaml
- Dockerfile updated for pnpm-based builds
- CLI logs command rewritten with chokidar for cross-platform reliability
- Router health checking added for auto-detected router mode
- Centralized path resolution via apps/worker/src/paths.ts
This commit is contained in:
ezl-keygraph
2026-03-18 15:58:45 +05:30
parent 9b1abd9ec0
commit 181f24cfcc
141 changed files with 3717 additions and 3997 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@shannon/mcp-server",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "catalog:",
"zod": "^4.3.6"
}
}
+46
View File
@@ -0,0 +1,46 @@
// 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.
/**
* Shannon Helper MCP Server
*
* In-process MCP server providing save_deliverable and generate_totp tools
* for Shannon penetration testing agents.
*
* Replaces bash script invocations with native tool access.
*
* Uses factory pattern to create tools with targetDir captured in closure,
* ensuring thread-safety when multiple workflows run in parallel.
*/
import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
import { generateTotpTool } from './tools/generate-totp.js';
import { createSaveDeliverableTool } from './tools/save-deliverable.js';
/**
* Create Shannon Helper MCP Server with target directory context
*
* Each workflow should create its own MCP server instance with its targetDir.
* The save_deliverable tool captures targetDir in a closure, preventing race
* conditions when multiple workflows run in parallel.
*/
export function createShannonHelperServer(targetDir: string): ReturnType<typeof createSdkMcpServer> {
// Create save_deliverable tool with targetDir in closure (no global variable)
const saveDeliverableTool = createSaveDeliverableTool(targetDir);
return createSdkMcpServer({
name: 'shannon-helper',
version: '1.0.0',
tools: [saveDeliverableTool, generateTotpTool],
});
}
export { generateTotpTool } from './tools/generate-totp.js';
// Export factory for direct usage if needed
export { createSaveDeliverableTool } from './tools/save-deliverable.js';
// Export types for external use
export * from './types/index.js';
@@ -0,0 +1,128 @@
// 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.
/**
* generate_totp MCP Tool
*
* Generates 6-digit TOTP codes for authentication.
* Replaces tools/generate-totp-standalone.mjs bash script.
* Based on RFC 6238 (TOTP) and RFC 4226 (HOTP).
*/
import { createHmac } from 'node:crypto';
import { tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import { createToolResult, type GenerateTotpResponse, type ToolResult } from '../types/tool-responses.js';
import { createCryptoError, createGenericError } from '../utils/error-formatter.js';
import { base32Decode, validateTotpSecret } from '../validation/totp-validator.js';
/**
* Input schema for generate_totp tool
*/
export const GenerateTotpInputSchema = z.object({
secret: z
.string()
.min(1)
.regex(/^[A-Z2-7]+$/i, 'Must be base32-encoded')
.describe('Base32-encoded TOTP secret'),
});
export type GenerateTotpInput = z.infer<typeof GenerateTotpInputSchema>;
/**
* Generate HOTP code (RFC 4226)
* Ported from generate-totp-standalone.mjs (lines 74-99)
*/
function generateHOTP(secret: string, counter: number, digits: number = 6): string {
const key = base32Decode(secret);
// Convert counter to 8-byte buffer (big-endian)
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
// Generate HMAC-SHA1
const hmac = createHmac('sha1', key);
hmac.update(counterBuffer);
const hash = hmac.digest();
// Dynamic truncation
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);
// Generate digits
const otp = (code % 10 ** digits).toString().padStart(digits, '0');
return otp;
}
/**
* Generate TOTP code (RFC 6238)
* Ported from generate-totp-standalone.mjs (lines 101-106)
*/
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);
}
/**
* Get seconds until TOTP code expires
*/
function getSecondsUntilExpiration(timeStep: number = 30): number {
const currentTime = Math.floor(Date.now() / 1000);
return timeStep - (currentTime % timeStep);
}
/**
* generate_totp tool implementation
*/
export async function generateTotp(args: GenerateTotpInput): Promise<ToolResult> {
try {
const { secret } = args;
// Validate secret (throws on error)
validateTotpSecret(secret);
// Generate TOTP code
const totpCode = generateTOTP(secret);
const expiresIn = getSecondsUntilExpiration();
const timestamp = new Date().toISOString();
// Success response
const successResponse: GenerateTotpResponse = {
status: 'success',
message: 'TOTP code generated successfully',
totpCode,
timestamp,
expiresIn,
};
return createToolResult(successResponse);
} catch (error) {
// Check if it's a validation/crypto error
if (error instanceof Error && (error.message.includes('base32') || error.message.includes('TOTP'))) {
const errorResponse = createCryptoError(error.message, false);
return createToolResult(errorResponse);
}
// Generic error
const errorResponse = createGenericError(error, false);
return createToolResult(errorResponse);
}
}
/**
* Tool definition for MCP server - created using SDK's tool() function
*/
export const generateTotpTool = tool(
'generate_totp',
'Generates 6-digit TOTP code for authentication. Secret must be base32-encoded.',
GenerateTotpInputSchema.shape,
generateTotp,
);
@@ -0,0 +1,159 @@
// 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.
/**
* save_deliverable MCP Tool
*
* Saves deliverable files with automatic validation.
* Replaces tools/save_deliverable.js bash script.
*
* Uses factory pattern to capture targetDir in closure, avoiding race conditions
* when multiple workflows run in parallel.
*/
import fs from 'node:fs';
import path from 'node:path';
import { tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
import { DELIVERABLE_FILENAMES, DeliverableType, isQueueType } from '../types/deliverables.js';
import { createToolResult, type SaveDeliverableResponse, type ToolResult } from '../types/tool-responses.js';
import { createGenericError, createValidationError } from '../utils/error-formatter.js';
import { saveDeliverableFile } from '../utils/file-operations.js';
import { validateQueueJson } from '../validation/queue-validator.js';
/**
* Input schema for save_deliverable tool
*/
export const SaveDeliverableInputSchema = z.object({
deliverable_type: z.nativeEnum(DeliverableType).describe('Type of deliverable to save'),
content: z
.string()
.min(1)
.optional()
.describe('File content (markdown for analysis/evidence, JSON for queues). Optional if file_path is provided.'),
file_path: z
.string()
.optional()
.describe(
'Path to a file whose contents should be used as the deliverable content. Relative paths are resolved against the deliverables directory. Use this instead of content for large reports to avoid output token limits.',
),
});
export type SaveDeliverableInput = z.infer<typeof SaveDeliverableInputSchema>;
/**
* Check if a path is contained within a base directory.
* Prevents path traversal attacks (e.g., ../../../etc/passwd).
*/
function isPathContained(basePath: string, targetPath: string): boolean {
const resolvedBase = path.resolve(basePath);
const resolvedTarget = path.resolve(targetPath);
return resolvedTarget === resolvedBase || resolvedTarget.startsWith(resolvedBase + path.sep);
}
/**
* Resolve deliverable content from either inline content or a file path.
* Returns the content string on success, or a ToolResult error on failure.
*/
function resolveContent(args: SaveDeliverableInput, targetDir: string): string | ToolResult {
if (args.content) {
return args.content;
}
if (!args.file_path) {
return createToolResult(
createValidationError('Either "content" or "file_path" must be provided', true, {
deliverableType: args.deliverable_type,
}),
);
}
const resolvedPath = path.isAbsolute(args.file_path) ? args.file_path : path.resolve(targetDir, args.file_path);
// Security: Prevent path traversal outside targetDir
if (!isPathContained(targetDir, resolvedPath)) {
return createToolResult(
createValidationError(`Path "${args.file_path}" resolves outside allowed directory`, false, {
deliverableType: args.deliverable_type,
allowedBase: targetDir,
}),
);
}
try {
return fs.readFileSync(resolvedPath, 'utf-8');
} catch (readError) {
return createToolResult(
createValidationError(
`Failed to read file at ${resolvedPath}: ${readError instanceof Error ? readError.message : String(readError)}`,
true,
{ deliverableType: args.deliverable_type, filePath: resolvedPath },
),
);
}
}
/**
* Create save_deliverable handler with targetDir captured in closure.
*
* This factory pattern ensures each MCP server instance has its own targetDir,
* preventing race conditions when multiple workflows run in parallel.
*/
function createSaveDeliverableHandler(targetDir: string) {
return async function saveDeliverable(args: SaveDeliverableInput): Promise<ToolResult> {
try {
const { deliverable_type } = args;
const contentOrError = resolveContent(args, targetDir);
if (typeof contentOrError !== 'string') {
return contentOrError;
}
const content = contentOrError;
if (isQueueType(deliverable_type)) {
const queueValidation = validateQueueJson(content);
if (!queueValidation.valid) {
return createToolResult(
createValidationError(queueValidation.message ?? 'Invalid queue JSON', true, {
deliverableType: deliverable_type,
expectedFormat: '{"vulnerabilities": [...]}',
}),
);
}
}
const filename = DELIVERABLE_FILENAMES[deliverable_type];
const filepath = saveDeliverableFile(targetDir, filename, content);
const successResponse: SaveDeliverableResponse = {
status: 'success',
message: `Deliverable saved successfully: ${filename}`,
filepath,
deliverableType: deliverable_type,
validated: isQueueType(deliverable_type),
};
return createToolResult(successResponse);
} catch (error) {
return createToolResult(createGenericError(error, false, { deliverableType: args.deliverable_type }));
}
};
}
/**
* Factory function to create save_deliverable tool with targetDir in closure
*
* Each MCP server instance should call this with its own targetDir to ensure
* deliverables are saved to the correct workflow's directory.
*/
export function createSaveDeliverableTool(targetDir: string) {
return tool(
'save_deliverable',
'Saves deliverable files with automatic validation. Queue files must have {"vulnerabilities": [...]} structure. For large reports, write the file to disk first then pass file_path instead of inline content to avoid output token limits.',
SaveDeliverableInputSchema.shape,
createSaveDeliverableHandler(targetDir),
);
}
@@ -0,0 +1,96 @@
// 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.
/**
* Deliverable Type Definitions
*
* Maps deliverable types to their filenames and defines validation requirements.
* Must match the exact mappings from tools/save_deliverable.js.
*/
export enum DeliverableType {
// Pre-recon agent
CODE_ANALYSIS = 'CODE_ANALYSIS',
// Recon agent
RECON = 'RECON',
// Vulnerability analysis agents
INJECTION_ANALYSIS = 'INJECTION_ANALYSIS',
INJECTION_QUEUE = 'INJECTION_QUEUE',
XSS_ANALYSIS = 'XSS_ANALYSIS',
XSS_QUEUE = 'XSS_QUEUE',
AUTH_ANALYSIS = 'AUTH_ANALYSIS',
AUTH_QUEUE = 'AUTH_QUEUE',
AUTHZ_ANALYSIS = 'AUTHZ_ANALYSIS',
AUTHZ_QUEUE = 'AUTHZ_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',
}
/**
* Hard-coded filename mappings from agent prompts
* Must match tools/save_deliverable.js exactly
*/
export const DELIVERABLE_FILENAMES: Record<DeliverableType, string> = {
[DeliverableType.CODE_ANALYSIS]: 'code_analysis_deliverable.md',
[DeliverableType.RECON]: 'recon_deliverable.md',
[DeliverableType.INJECTION_ANALYSIS]: 'injection_analysis_deliverable.md',
[DeliverableType.INJECTION_QUEUE]: 'injection_exploitation_queue.json',
[DeliverableType.XSS_ANALYSIS]: 'xss_analysis_deliverable.md',
[DeliverableType.XSS_QUEUE]: 'xss_exploitation_queue.json',
[DeliverableType.AUTH_ANALYSIS]: 'auth_analysis_deliverable.md',
[DeliverableType.AUTH_QUEUE]: 'auth_exploitation_queue.json',
[DeliverableType.AUTHZ_ANALYSIS]: 'authz_analysis_deliverable.md',
[DeliverableType.AUTHZ_QUEUE]: 'authz_exploitation_queue.json',
[DeliverableType.SSRF_ANALYSIS]: 'ssrf_analysis_deliverable.md',
[DeliverableType.SSRF_QUEUE]: 'ssrf_exploitation_queue.json',
[DeliverableType.INJECTION_EVIDENCE]: 'injection_exploitation_evidence.md',
[DeliverableType.XSS_EVIDENCE]: 'xss_exploitation_evidence.md',
[DeliverableType.AUTH_EVIDENCE]: 'auth_exploitation_evidence.md',
[DeliverableType.AUTHZ_EVIDENCE]: 'authz_exploitation_evidence.md',
[DeliverableType.SSRF_EVIDENCE]: 'ssrf_exploitation_evidence.md',
};
/**
* Queue types that require JSON validation
*/
export const QUEUE_TYPES: DeliverableType[] = [
DeliverableType.INJECTION_QUEUE,
DeliverableType.XSS_QUEUE,
DeliverableType.AUTH_QUEUE,
DeliverableType.AUTHZ_QUEUE,
DeliverableType.SSRF_QUEUE,
];
/**
* Type guard to check if a deliverable type is a queue
*/
export function isQueueType(type: string): boolean {
return QUEUE_TYPES.includes(type as DeliverableType);
}
/**
* Vulnerability queue structure
*/
export interface VulnerabilityQueue {
vulnerabilities: VulnerabilityItem[];
}
export interface VulnerabilityItem {
[key: string]: unknown;
}
+12
View File
@@ -0,0 +1,12 @@
// 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 './deliverables.js';
export * from './tool-responses.js';
@@ -0,0 +1,69 @@
// Copyright (C) 2025 Keygraph, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation.
/**
* Tool Response Type Definitions
*
* Defines structured response formats for MCP tools to ensure
* consistent error handling and success reporting.
*/
export interface ErrorResponse {
status: 'error';
message: string;
errorType: string; // ValidationError, FileSystemError, CryptoError, etc.
retryable: boolean;
context?: Record<string, unknown>;
}
export interface SuccessResponse {
status: 'success';
message: string;
}
export interface SaveDeliverableResponse {
status: 'success';
message: string;
filepath: string;
deliverableType: string;
validated: boolean; // true if queue JSON was validated
}
export interface GenerateTotpResponse {
status: 'success';
message: string;
totpCode: string;
timestamp: string;
expiresIn: number; // seconds until expiration
}
export type ToolResponse = ErrorResponse | SuccessResponse | SaveDeliverableResponse | GenerateTotpResponse;
export interface ToolResultContent {
type: string;
text: string;
}
export interface ToolResult {
content: ToolResultContent[];
isError: boolean;
}
/**
* Helper to create tool result from response
* MCP tools should return this format
*/
export function createToolResult(response: ToolResponse): ToolResult {
return {
content: [
{
type: 'text',
text: JSON.stringify(response, null, 2),
},
],
isError: response.status === 'error',
};
}
@@ -0,0 +1,67 @@
// 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 Formatting Utilities
*
* Helper functions for creating structured error responses.
*/
import type { ErrorResponse } from '../types/tool-responses.js';
/**
* Create a validation error response
*/
export function createValidationError(
message: string,
retryable: boolean = true,
context?: Record<string, unknown>,
): ErrorResponse {
return {
status: 'error',
message,
errorType: 'ValidationError',
retryable,
...(context !== undefined && { context }),
};
}
/**
* Create a crypto error response
*/
export function createCryptoError(
message: string,
retryable: boolean = false,
context?: Record<string, unknown>,
): ErrorResponse {
return {
status: 'error',
message,
errorType: 'CryptoError',
retryable,
...(context !== undefined && { context }),
};
}
/**
* Create a generic error response
*/
export function createGenericError(
error: unknown,
retryable: boolean = false,
context?: Record<string, unknown>,
): ErrorResponse {
const message = error instanceof Error ? error.message : String(error);
const errorType = error instanceof Error ? error.constructor.name : 'UnknownError';
return {
status: 'error',
message,
errorType,
retryable,
...(context !== undefined && { context }),
};
}
@@ -0,0 +1,39 @@
// 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.
/**
* File Operations Utilities
*
* Handles file system operations for deliverable saving.
* Ported from tools/save_deliverable.js (lines 117-130).
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Save deliverable file to deliverables/ directory
*
* @param targetDir - Target directory for deliverables (passed explicitly to avoid race conditions)
* @param filename - Name of the deliverable file
* @param content - File content to save
*/
export function saveDeliverableFile(targetDir: string, filename: string, content: string): string {
const deliverablesDir = join(targetDir, 'deliverables');
const filepath = join(deliverablesDir, filename);
// Ensure deliverables directory exists
try {
mkdirSync(deliverablesDir, { recursive: true });
} catch {
throw new Error(`Cannot create deliverables directory at ${deliverablesDir}`);
}
// Write file (atomic write - single operation)
writeFileSync(filepath, content, 'utf8');
return filepath;
}
@@ -0,0 +1,65 @@
// 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.
/**
* Queue Validator
*
* Validates JSON structure for vulnerability queue files.
* Ported from tools/save_deliverable.js (lines 56-75).
*/
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
*/
export function validateQueueJson(content: string): ValidationResult {
try {
const parsed = JSON.parse(content) as unknown;
// Type guard for the parsed result
if (typeof parsed !== 'object' || parsed === null) {
return {
valid: false,
message: `Invalid queue structure: Expected an object. Got: ${typeof parsed}`,
};
}
const obj = parsed as Record<string, unknown>;
// Queue files must have a 'vulnerabilities' array
if (!('vulnerabilities' in obj)) {
return {
valid: false,
message: `Invalid queue structure: Missing 'vulnerabilities' property. Expected: {"vulnerabilities": [...]}`,
};
}
if (!Array.isArray(obj.vulnerabilities)) {
return {
valid: false,
message: `Invalid queue structure: 'vulnerabilities' must be an array. Expected: {"vulnerabilities": [...]}`,
};
}
return {
valid: true,
data: parsed as VulnerabilityQueue,
};
} catch (error) {
return {
valid: false,
message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
@@ -0,0 +1,73 @@
// Copyright (C) 2025 Keygraph, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation.
/**
* TOTP Validator
*
* Validates TOTP secrets and provides base32 decoding.
* Ported from tools/generate-totp-standalone.mjs (lines 43-72).
*/
/**
* Base32 decode function
* Ported from generate-totp-standalone.mjs
*/
export function base32Decode(encoded: string): Buffer {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const cleanInput = encoded.toUpperCase().replace(/[^A-Z2-7]/g, '');
if (cleanInput.length === 0) {
return Buffer.alloc(0);
}
const output: number[] = [];
let bits = 0;
let value = 0;
for (const char of cleanInput) {
const index = alphabet.indexOf(char);
if (index === -1) {
throw new Error(`Invalid base32 character: ${char}`);
}
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(output);
}
/**
* Validate TOTP secret
* Must be base32-encoded string
*
* @returns true if valid, throws Error if invalid
*/
export function validateTotpSecret(secret: string): boolean {
if (!secret || secret.length === 0) {
throw new Error('TOTP secret cannot be empty');
}
// Check if it's valid base32 (only A-Z and 2-7, case-insensitive)
const base32Regex = /^[A-Z2-7]+$/i;
if (!base32Regex.test(secret.replace(/[^A-Z2-7]/gi, ''))) {
throw new Error('TOTP secret must be base32-encoded (characters A-Z and 2-7)');
}
// Try to decode to ensure it's valid
try {
base32Decode(secret);
} catch (error) {
throw new Error(`Invalid TOTP secret: ${error instanceof Error ? error.message : String(error)}`);
}
return true;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}