mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-07-06 05:07:59 +02:00
feat: migrate from MCP tools to CLI based tools (#252)
* feat: migrate from MCP tools to CLI tools * fix: restore browser action emoji formatters for CLI output Adapt formatBrowserAction for playwright-cli commands, replacing the old mcp__playwright__browser_* tool name matching removed during migration.
This commit is contained in:
@@ -7,13 +7,11 @@
|
||||
// Production Claude agent execution with retry, git checkpoints, and audit logging
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createShannonHelperServer } from '@shannon/mcp-server';
|
||||
import { fs, path } from 'zx';
|
||||
import type { AuditSession } from '../audit/index.js';
|
||||
import { isRetryableError, PentestError } from '../services/error-handling.js';
|
||||
import { AGENT_VALIDATORS, AGENTS, MCP_AGENT_MAPPING } from '../session-manager.js';
|
||||
import { AGENT_VALIDATORS } from '../session-manager.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import type { AgentName } from '../types/index.js';
|
||||
import { isSpendingCapBehavior } from '../utils/billing-detection.js';
|
||||
import { formatTimestamp } from '../utils/formatting.js';
|
||||
import { Timer } from '../utils/metrics.js';
|
||||
@@ -43,89 +41,6 @@ export interface ClaudePromptResult {
|
||||
retryable?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface StdioMcpServer {
|
||||
type: 'stdio';
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
type McpServer = ReturnType<typeof createShannonHelperServer> | StdioMcpServer;
|
||||
|
||||
// Configures MCP servers for agent execution, with Docker-specific Chromium handling
|
||||
function buildMcpServers(
|
||||
sourceDir: string,
|
||||
agentName: string | null,
|
||||
logger: ActivityLogger,
|
||||
): Record<string, McpServer> {
|
||||
// 1. Create the shannon-helper server (always present)
|
||||
const shannonHelperServer = createShannonHelperServer(sourceDir);
|
||||
|
||||
const mcpServers: Record<string, McpServer> = {
|
||||
'shannon-helper': shannonHelperServer,
|
||||
};
|
||||
|
||||
// 2. Look up the agent's Playwright MCP mapping
|
||||
if (agentName) {
|
||||
const promptTemplate = AGENTS[agentName as AgentName].promptTemplate;
|
||||
const playwrightMcpName = MCP_AGENT_MAPPING[promptTemplate as keyof typeof MCP_AGENT_MAPPING] || null;
|
||||
|
||||
if (playwrightMcpName) {
|
||||
logger.info(`Assigned ${agentName} -> ${playwrightMcpName}`);
|
||||
|
||||
const userDataDir = `/tmp/${playwrightMcpName}`;
|
||||
|
||||
// 3. Configure Playwright MCP args with Docker/local browser handling
|
||||
const isDocker = process.env.SHANNON_DOCKER === 'true';
|
||||
|
||||
const mcpArgs: string[] = ['@playwright/mcp@0.0.68', '--isolated', '--user-data-dir', userDataDir];
|
||||
|
||||
if (isDocker) {
|
||||
mcpArgs.push('--executable-path', '/usr/bin/chromium-browser');
|
||||
mcpArgs.push('--browser', 'chromium');
|
||||
}
|
||||
|
||||
// NOTE: Explicit allowlist — the Playwright MCP subprocess must not inherit
|
||||
// secrets (API keys, AWS tokens) from the parent process.
|
||||
const MCP_ENV_ALLOWLIST = [
|
||||
'PATH',
|
||||
'HOME',
|
||||
'NODE_PATH',
|
||||
'DISPLAY',
|
||||
'PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH',
|
||||
] as const;
|
||||
|
||||
const envVars: Record<string, string> = {
|
||||
PLAYWRIGHT_HEADLESS: 'true',
|
||||
...(isDocker && { PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' }),
|
||||
};
|
||||
|
||||
for (const key of MCP_ENV_ALLOWLIST) {
|
||||
const val = process.env[key];
|
||||
if (val) {
|
||||
envVars[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith('XDG_') && value !== undefined) {
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
mcpServers[playwrightMcpName] = {
|
||||
type: 'stdio' as const,
|
||||
command: 'npx',
|
||||
args: mcpArgs,
|
||||
env: envVars,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Return configured servers
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
function outputLines(lines: string[]): void {
|
||||
for (const line of lines) {
|
||||
console.log(line);
|
||||
@@ -213,7 +128,7 @@ export async function runClaudePrompt(
|
||||
sourceDir: string,
|
||||
context: string = '',
|
||||
description: string = 'Claude analysis',
|
||||
agentName: string | null = null,
|
||||
_agentName: string | null = null,
|
||||
auditSession: AuditSession | null = null,
|
||||
logger: ActivityLogger,
|
||||
modelTier: ModelTier = 'medium',
|
||||
@@ -232,10 +147,7 @@ export async function runClaudePrompt(
|
||||
|
||||
logger.info(`Running Claude Code: ${description}...`);
|
||||
|
||||
// 3. Configure MCP servers
|
||||
const mcpServers = buildMcpServers(sourceDir, agentName, logger);
|
||||
|
||||
// 4. Build env vars to pass to SDK subprocesses
|
||||
// 3. Build env vars to pass to SDK subprocesses
|
||||
const sdkEnv: Record<string, string> = {
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || '64000',
|
||||
};
|
||||
@@ -254,6 +166,9 @@ export async function runClaudePrompt(
|
||||
'ANTHROPIC_SMALL_MODEL',
|
||||
'ANTHROPIC_MEDIUM_MODEL',
|
||||
'ANTHROPIC_LARGE_MODEL',
|
||||
'HOME',
|
||||
'PATH',
|
||||
'PLAYWRIGHT_MCP_EXECUTABLE_PATH',
|
||||
];
|
||||
for (const name of passthroughVars) {
|
||||
const val = process.env[name];
|
||||
@@ -262,14 +177,14 @@ export async function runClaudePrompt(
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Configure SDK options
|
||||
// 4. Configure SDK options
|
||||
const options = {
|
||||
model: resolveModel(modelTier),
|
||||
maxTurns: 10_000,
|
||||
cwd: sourceDir,
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
mcpServers,
|
||||
settingSources: ['user'] as ('user' | 'project' | 'local')[],
|
||||
env: sdkEnv,
|
||||
};
|
||||
|
||||
|
||||
@@ -308,10 +308,6 @@ export async function dispatchMessage(
|
||||
const actualModel = getActualModelName(initMsg.model);
|
||||
if (!execContext.useCleanOutput) {
|
||||
logger.info(`Model: ${actualModel}, Permission: ${initMsg.permissionMode}`);
|
||||
if (initMsg.mcp_servers && initMsg.mcp_servers.length > 0) {
|
||||
const mcpStatus = initMsg.mcp_servers.map((s) => `${s.name}(${s.status})`).join(', ');
|
||||
logger.info(`MCP: ${mcpStatus}`);
|
||||
}
|
||||
}
|
||||
// Return actual model for tracking in audit logs
|
||||
return { type: 'continue', model: actualModel };
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ToolCallInput {
|
||||
text?: string;
|
||||
action?: string;
|
||||
description?: string;
|
||||
command?: string;
|
||||
todos?: Array<{
|
||||
status: string;
|
||||
content: string;
|
||||
@@ -76,6 +77,80 @@ function extractDomain(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format playwright-cli commands into clean progress indicators
|
||||
*/
|
||||
function formatBrowserAction(command: string): string | null {
|
||||
// Extract subcommand after optional session flag (e.g., "playwright-cli -s=session1 navigate https://example.com")
|
||||
const match = command.match(/playwright-cli\s+(?:-s=\S+\s+)?(\S+)(?:\s+(.*))?/);
|
||||
if (!match) return null;
|
||||
|
||||
const subcommand = match[1];
|
||||
const args = match[2] || '';
|
||||
|
||||
switch (subcommand) {
|
||||
case 'open':
|
||||
case 'goto': {
|
||||
const domain = args.trim() ? extractDomain(args.trim()) : '';
|
||||
return domain ? `🌐 Navigating to ${domain}` : '🌐 Opening browser';
|
||||
}
|
||||
case 'go-back':
|
||||
return '⬅️ Going back';
|
||||
case 'go-forward':
|
||||
return '➡️ Going forward';
|
||||
case 'reload':
|
||||
return '🔄 Reloading page';
|
||||
case 'click':
|
||||
case 'dblclick':
|
||||
return `🖱️ Clicking ${(args || 'element').slice(0, 25)}`;
|
||||
case 'hover':
|
||||
return `👆 Hovering over ${(args || 'element').slice(0, 20)}`;
|
||||
case 'type':
|
||||
return `⌨️ Typing ${(args || 'text').slice(0, 20)}`;
|
||||
case 'press':
|
||||
case 'keydown':
|
||||
case 'keyup':
|
||||
return `⌨️ Pressing ${args || 'key'}`;
|
||||
case 'fill':
|
||||
return `📝 Filling ${(args || 'field').slice(0, 25)}`;
|
||||
case 'select':
|
||||
return '📋 Selecting dropdown option';
|
||||
case 'check':
|
||||
case 'uncheck':
|
||||
return `☑️ ${subcommand === 'check' ? 'Checking' : 'Unchecking'} ${(args || 'element').slice(0, 20)}`;
|
||||
case 'upload':
|
||||
return '📁 Uploading file';
|
||||
case 'drag':
|
||||
return '🖱️ Dragging element';
|
||||
case 'snapshot':
|
||||
return '📸 Taking page snapshot';
|
||||
case 'screenshot':
|
||||
return '📸 Taking screenshot';
|
||||
case 'eval':
|
||||
case 'run-code':
|
||||
return '🔍 Running JavaScript analysis';
|
||||
case 'console':
|
||||
return '📜 Checking console logs';
|
||||
case 'network':
|
||||
return '🌐 Analyzing network traffic';
|
||||
case 'tab-list':
|
||||
case 'tab-new':
|
||||
case 'tab-close':
|
||||
case 'tab-select':
|
||||
return `🗂️ ${subcommand.replace('tab-', '')} browser tab`;
|
||||
case 'dialog-accept':
|
||||
return '💬 Accepting dialog';
|
||||
case 'dialog-dismiss':
|
||||
return '💬 Dismissing dialog';
|
||||
case 'pdf':
|
||||
return '📄 Saving page as PDF';
|
||||
case 'resize':
|
||||
return `🖥️ Resizing browser ${args || ''}`.trim();
|
||||
default:
|
||||
return `🌐 Browser: ${subcommand}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize TodoWrite updates into clean progress indicators
|
||||
*/
|
||||
@@ -103,104 +178,6 @@ function summarizeTodoUpdate(input: ToolCallInput | undefined): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format browser tool calls into clean progress indicators
|
||||
*/
|
||||
function formatBrowserAction(toolCall: ToolCall): string {
|
||||
const toolName = toolCall.name;
|
||||
const input = toolCall.input || {};
|
||||
|
||||
// Core Browser Operations
|
||||
if (toolName === 'mcp__playwright__browser_navigate') {
|
||||
const url = input.url || '';
|
||||
const domain = extractDomain(url);
|
||||
return `🌐 Navigating to ${domain}`;
|
||||
}
|
||||
|
||||
if (toolName === 'mcp__playwright__browser_navigate_back') {
|
||||
return `⬅️ Going back`;
|
||||
}
|
||||
|
||||
// Page Interaction
|
||||
if (toolName === 'mcp__playwright__browser_click') {
|
||||
const element = input.element || 'element';
|
||||
return `🖱️ Clicking ${element.slice(0, 25)}`;
|
||||
}
|
||||
|
||||
if (toolName === 'mcp__playwright__browser_hover') {
|
||||
const element = input.element || 'element';
|
||||
return `👆 Hovering over ${element.slice(0, 20)}`;
|
||||
}
|
||||
|
||||
if (toolName === 'mcp__playwright__browser_type') {
|
||||
const element = input.element || 'field';
|
||||
return `⌨️ Typing in ${element.slice(0, 20)}`;
|
||||
}
|
||||
|
||||
if (toolName === 'mcp__playwright__browser_press_key') {
|
||||
const key = input.key || 'key';
|
||||
return `⌨️ Pressing ${key}`;
|
||||
}
|
||||
|
||||
// Form Handling
|
||||
if (toolName === 'mcp__playwright__browser_fill_form') {
|
||||
const fieldCount = input.fields?.length || 0;
|
||||
return `📝 Filling ${fieldCount} form fields`;
|
||||
}
|
||||
|
||||
if (toolName === 'mcp__playwright__browser_select_option') {
|
||||
return `📋 Selecting dropdown option`;
|
||||
}
|
||||
|
||||
if (toolName === 'mcp__playwright__browser_file_upload') {
|
||||
return `📁 Uploading file`;
|
||||
}
|
||||
|
||||
// Page Analysis
|
||||
if (toolName === 'mcp__playwright__browser_snapshot') {
|
||||
return `📸 Taking page snapshot`;
|
||||
}
|
||||
|
||||
if (toolName === 'mcp__playwright__browser_take_screenshot') {
|
||||
return `📸 Taking screenshot`;
|
||||
}
|
||||
|
||||
if (toolName === 'mcp__playwright__browser_evaluate') {
|
||||
return `🔍 Running JavaScript analysis`;
|
||||
}
|
||||
|
||||
// Waiting & Monitoring
|
||||
if (toolName === 'mcp__playwright__browser_wait_for') {
|
||||
if (input.text) {
|
||||
return `⏳ Waiting for "${input.text.slice(0, 20)}"`;
|
||||
}
|
||||
return `⏳ Waiting for page response`;
|
||||
}
|
||||
|
||||
if (toolName === 'mcp__playwright__browser_console_messages') {
|
||||
return `📜 Checking console logs`;
|
||||
}
|
||||
|
||||
if (toolName === 'mcp__playwright__browser_network_requests') {
|
||||
return `🌐 Analyzing network traffic`;
|
||||
}
|
||||
|
||||
// Tab Management
|
||||
if (toolName === 'mcp__playwright__browser_tabs') {
|
||||
const action = input.action || 'managing';
|
||||
return `🗂️ ${action} browser tab`;
|
||||
}
|
||||
|
||||
// Dialog Handling
|
||||
if (toolName === 'mcp__playwright__browser_handle_dialog') {
|
||||
return `💬 Handling browser dialog`;
|
||||
}
|
||||
|
||||
// Fallback for any missed tools
|
||||
const actionType = toolName.split('_').pop();
|
||||
return `🌐 Browser: ${actionType}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out JSON tool calls from content, with special handling for Task calls
|
||||
*/
|
||||
@@ -241,11 +218,14 @@ export function filterJsonToolCalls(content: string | null | undefined): string
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special handling for browser tool calls
|
||||
if (toolCall.name.startsWith('mcp__playwright__browser_')) {
|
||||
const browserAction = formatBrowserAction(toolCall);
|
||||
if (browserAction) {
|
||||
processedLines.push(browserAction);
|
||||
// Special handling for browser tool calls (playwright-cli via Bash)
|
||||
if (toolCall.name === 'Bash') {
|
||||
const command = toolCall.input?.command || '';
|
||||
if (command.includes('playwright-cli')) {
|
||||
const browserAction = formatBrowserAction(command);
|
||||
if (browserAction) {
|
||||
processedLines.push(browserAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -92,7 +92,6 @@ export interface SystemInitMessage {
|
||||
subtype: 'init';
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
mcp_servers?: Array<{ name: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface UserMessage {
|
||||
|
||||
@@ -260,22 +260,6 @@ export class WorkflowLogger {
|
||||
return String(p.url);
|
||||
}
|
||||
break;
|
||||
case 'mcp__playwright__browser_navigate':
|
||||
if (p.url) {
|
||||
return String(p.url);
|
||||
}
|
||||
break;
|
||||
case 'mcp__playwright__browser_click':
|
||||
if (p.selector) {
|
||||
return this.truncate(String(p.selector), 60);
|
||||
}
|
||||
break;
|
||||
case 'mcp__playwright__browser_type':
|
||||
if (p.selector) {
|
||||
const text = p.text ? `: "${this.truncate(String(p.text), 30)}"` : '';
|
||||
return `${this.truncate(String(p.selector), 40)}${text}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Default: show first string-valued param truncated
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/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.
|
||||
|
||||
/**
|
||||
* generate-totp CLI
|
||||
*
|
||||
* Generates 6-digit TOTP codes for authentication.
|
||||
* Replaces the MCP generate_totp tool.
|
||||
* Based on RFC 6238 (TOTP) and RFC 4226 (HOTP).
|
||||
*
|
||||
* Usage:
|
||||
* generate-totp --secret JBSWY3DPEHPK3PXP
|
||||
*/
|
||||
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
// === Base32 Decoding ===
|
||||
|
||||
function base32Decode(encoded: string): Buffer {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const cleanInput = encoded.toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||
|
||||
if (cleanInput.length === 0) {
|
||||
throw new Error('TOTP secret is empty after cleaning');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// === TOTP Generation (RFC 6238) ===
|
||||
|
||||
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 (SHA-1 always produces 20 bytes)
|
||||
const lastByte = hash[hash.length - 1] ?? 0;
|
||||
const offset = lastByte & 0x0f;
|
||||
const code =
|
||||
(((hash[offset] ?? 0) & 0x7f) << 24) |
|
||||
(((hash[offset + 1] ?? 0) & 0xff) << 16) |
|
||||
(((hash[offset + 2] ?? 0) & 0xff) << 8) |
|
||||
((hash[offset + 3] ?? 0) & 0xff);
|
||||
|
||||
return (code % 10 ** digits).toString().padStart(digits, '0');
|
||||
}
|
||||
|
||||
function generateTOTP(secret: string, timeStep: number = 30, digits: number = 6): string {
|
||||
const counter = Math.floor(Date.now() / 1000 / timeStep);
|
||||
return generateHOTP(secret, counter, digits);
|
||||
}
|
||||
|
||||
// === Argument Parsing ===
|
||||
|
||||
function parseSecret(argv: string[]): string {
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const next = argv[i + 1];
|
||||
if (argv[i] === '--secret' && next) {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// === Main ===
|
||||
|
||||
function main(): void {
|
||||
const secret = parseSecret(process.argv);
|
||||
|
||||
if (!secret) {
|
||||
console.log(JSON.stringify({ status: 'error', message: 'Missing required --secret argument', retryable: false }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const base32Regex = /^[A-Z2-7]+$/i;
|
||||
if (!base32Regex.test(secret)) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Secret must be base32-encoded (characters A-Z and 2-7)',
|
||||
retryable: false,
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const totpCode = generateTOTP(secret);
|
||||
const expiresIn = 30 - (Math.floor(Date.now() / 1000) % 30);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
status: 'success',
|
||||
totpCode,
|
||||
expiresIn,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.log(JSON.stringify({ status: 'error', message: `TOTP generation failed: ${msg}`, retryable: false }));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/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.
|
||||
|
||||
/**
|
||||
* save-deliverable CLI
|
||||
*
|
||||
* Standalone script to save deliverable files with validation.
|
||||
* Replaces the MCP save_deliverable tool.
|
||||
*
|
||||
* Usage:
|
||||
* node save-deliverable.js --type INJECTION_QUEUE --content '{"vulnerabilities": [...]}'
|
||||
* node save-deliverable.js --type INJECTION_ANALYSIS --file-path deliverables/injection_analysis_deliverable.md
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { DELIVERABLE_FILENAMES, type DeliverableType, isQueueType } from '../types/deliverables.js';
|
||||
|
||||
// === Argument Parsing ===
|
||||
|
||||
interface ParsedArgs {
|
||||
type: string;
|
||||
content?: string;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const args: ParsedArgs = { type: '' };
|
||||
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = argv[i + 1];
|
||||
|
||||
if (arg === '--type' && next) {
|
||||
args.type = next;
|
||||
i++;
|
||||
} else if (arg === '--content' && next) {
|
||||
args.content = next;
|
||||
i++;
|
||||
} else if (arg === '--file-path' && next) {
|
||||
args.filePath = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// === Queue Validation ===
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function validateQueueJson(content: string): ValidationResult {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
|
||||
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>;
|
||||
|
||||
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 };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// === File Operations ===
|
||||
|
||||
function saveDeliverableFile(targetDir: string, filename: string, content: string): string {
|
||||
const deliverablesDir = join(targetDir, 'deliverables');
|
||||
const filepath = join(deliverablesDir, filename);
|
||||
|
||||
try {
|
||||
mkdirSync(deliverablesDir, { recursive: true });
|
||||
} catch {
|
||||
throw new Error(`Cannot create deliverables directory at ${deliverablesDir}`);
|
||||
}
|
||||
|
||||
writeFileSync(filepath, content, 'utf8');
|
||||
return filepath;
|
||||
}
|
||||
|
||||
// === Main ===
|
||||
|
||||
function main(): void {
|
||||
const args = parseArgs(process.argv);
|
||||
|
||||
// 1. Validate --type
|
||||
if (!args.type) {
|
||||
console.log(JSON.stringify({ status: 'error', message: 'Missing required --type argument', retryable: false }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const deliverableType = args.type as DeliverableType;
|
||||
const filename = DELIVERABLE_FILENAMES[deliverableType];
|
||||
|
||||
if (!filename) {
|
||||
console.log(
|
||||
JSON.stringify({ status: 'error', message: `Unknown deliverable type: ${args.type}`, retryable: false }),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Resolve content from --content or --file-path
|
||||
let content: string;
|
||||
|
||||
if (args.content) {
|
||||
content = args.content;
|
||||
} else if (args.filePath) {
|
||||
// Path traversal protection: must resolve inside cwd
|
||||
const cwd = process.cwd();
|
||||
const resolved = resolve(cwd, args.filePath);
|
||||
if (!resolved.startsWith(`${cwd}/`) && resolved !== cwd) {
|
||||
console.log(
|
||||
JSON.stringify({ status: 'error', message: `Path traversal detected: ${args.filePath}`, retryable: false }),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
content = readFileSync(resolved, 'utf8');
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.log(JSON.stringify({ status: 'error', message: `Failed to read file: ${msg}`, retryable: true }));
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
status: 'error',
|
||||
message: 'Either --content or --file-path is required',
|
||||
retryable: false,
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Validate queue types
|
||||
let validated = false;
|
||||
if (isQueueType(args.type)) {
|
||||
const validation = validateQueueJson(content);
|
||||
if (!validation.valid) {
|
||||
console.log(JSON.stringify({ status: 'error', message: validation.message, retryable: true }));
|
||||
process.exit(1);
|
||||
}
|
||||
validated = true;
|
||||
}
|
||||
|
||||
// 4. Save the file
|
||||
try {
|
||||
const targetDir = process.cwd();
|
||||
const filepath = saveDeliverableFile(targetDir, filename, content);
|
||||
console.log(JSON.stringify({ status: 'success', filepath, validated }));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.log(JSON.stringify({ status: 'error', message: `Failed to save: ${msg}`, retryable: true }));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -63,7 +63,6 @@ const RETRYABLE_PATTERNS = [
|
||||
'service unavailable',
|
||||
'bad gateway',
|
||||
// Claude API errors
|
||||
'mcp server',
|
||||
'model unavailable',
|
||||
'service temporarily unavailable',
|
||||
'api error',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { fs, path } from 'zx';
|
||||
import { PROMPTS_DIR } from '../paths.js';
|
||||
import { MCP_AGENT_MAPPING } from '../session-manager.js';
|
||||
import { PLAYWRIGHT_SESSION_MAPPING } from '../session-manager.js';
|
||||
import type { ActivityLogger } from '../types/activity-logger.js';
|
||||
import type { Authentication, DistributedConfig } from '../types/config.js';
|
||||
import { handlePromptError, PentestError } from './error-handling.js';
|
||||
@@ -14,7 +14,7 @@ import { handlePromptError, PentestError } from './error-handling.js';
|
||||
interface PromptVariables {
|
||||
webUrl: string;
|
||||
repoPath: string;
|
||||
MCP_SERVER?: string;
|
||||
PLAYWRIGHT_SESSION?: string;
|
||||
}
|
||||
|
||||
interface IncludeReplacement {
|
||||
@@ -166,7 +166,7 @@ async function interpolateVariables(
|
||||
let result = template
|
||||
.replace(/{{WEB_URL}}/g, variables.webUrl)
|
||||
.replace(/{{REPO_PATH}}/g, variables.repoPath)
|
||||
.replace(/{{MCP_SERVER}}/g, variables.MCP_SERVER || 'playwright-agent1')
|
||||
.replace(/{{PLAYWRIGHT_SESSION}}/g, variables.PLAYWRIGHT_SESSION || 'agent1')
|
||||
.replace(/{{AUTH_CONTEXT}}/g, buildAuthContext(config));
|
||||
|
||||
if (config) {
|
||||
@@ -236,16 +236,16 @@ export async function loadPrompt(
|
||||
throw new PentestError(`Prompt file not found: ${promptPath}`, 'prompt', false, { promptName, promptPath });
|
||||
}
|
||||
|
||||
// 2. Assign MCP server based on agent name
|
||||
// 2. Assign Playwright session based on agent name
|
||||
const enhancedVariables: PromptVariables = { ...variables };
|
||||
|
||||
const mcpServer = MCP_AGENT_MAPPING[promptName as keyof typeof MCP_AGENT_MAPPING];
|
||||
if (mcpServer) {
|
||||
enhancedVariables.MCP_SERVER = mcpServer;
|
||||
logger.info(`Assigned ${promptName} -> ${enhancedVariables.MCP_SERVER}`);
|
||||
const session = PLAYWRIGHT_SESSION_MAPPING[promptName as keyof typeof PLAYWRIGHT_SESSION_MAPPING];
|
||||
if (session) {
|
||||
enhancedVariables.PLAYWRIGHT_SESSION = session;
|
||||
logger.info(`Assigned ${promptName} -> ${enhancedVariables.PLAYWRIGHT_SESSION}`);
|
||||
} else {
|
||||
enhancedVariables.MCP_SERVER = 'playwright-agent1';
|
||||
logger.warn(`Unknown agent ${promptName}, using fallback -> ${enhancedVariables.MCP_SERVER}`);
|
||||
enhancedVariables.PLAYWRIGHT_SESSION = 'agent1';
|
||||
logger.warn(`Unknown agent ${promptName}, using fallback -> ${enhancedVariables.PLAYWRIGHT_SESSION}`);
|
||||
}
|
||||
|
||||
// 3. Read template file
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
import { fs, path } from 'zx';
|
||||
import { validateQueueAndDeliverable } from './services/queue-validation.js';
|
||||
import type { ActivityLogger } from './types/activity-logger.js';
|
||||
import type { AgentDefinition, AgentName, AgentValidator, PlaywrightAgent, VulnType } from './types/index.js';
|
||||
import type { AgentDefinition, AgentName, AgentValidator, PlaywrightSession, VulnType } from './types/index.js';
|
||||
|
||||
// Agent definitions according to PRD
|
||||
// NOTE: deliverableFilename values must match mcp-server/src/types/deliverables.ts:DELIVERABLE_FILENAMES
|
||||
export const AGENTS: Readonly<Record<AgentName, AgentDefinition>> = Object.freeze({
|
||||
'pre-recon': {
|
||||
name: 'pre-recon',
|
||||
@@ -149,35 +148,31 @@ function createExploitValidator(vulnType: VulnType): AgentValidator {
|
||||
};
|
||||
}
|
||||
|
||||
// MCP agent mapping - assigns each agent to a specific Playwright instance to prevent conflicts
|
||||
// Playwright session mapping - assigns each agent to a specific session for browser isolation
|
||||
// Keys are promptTemplate values from AGENTS registry
|
||||
export const MCP_AGENT_MAPPING: Record<string, PlaywrightAgent> = Object.freeze({
|
||||
// Phase 1: Pre-reconnaissance (actual prompt name is 'pre-recon-code')
|
||||
// NOTE: Pre-recon is pure code analysis and doesn't use browser automation,
|
||||
// but assigning MCP server anyway for consistency and future extensibility
|
||||
'pre-recon-code': 'playwright-agent1',
|
||||
export const PLAYWRIGHT_SESSION_MAPPING: Record<string, PlaywrightSession> = Object.freeze({
|
||||
// Phase 1: Pre-reconnaissance
|
||||
'pre-recon-code': 'agent1',
|
||||
|
||||
// Phase 2: Reconnaissance (actual prompt name is 'recon')
|
||||
recon: 'playwright-agent2',
|
||||
// Phase 2: Reconnaissance
|
||||
recon: 'agent2',
|
||||
|
||||
// Phase 3: Vulnerability Analysis (5 parallel agents)
|
||||
'vuln-injection': 'playwright-agent1',
|
||||
'vuln-xss': 'playwright-agent2',
|
||||
'vuln-auth': 'playwright-agent3',
|
||||
'vuln-ssrf': 'playwright-agent4',
|
||||
'vuln-authz': 'playwright-agent5',
|
||||
'vuln-injection': 'agent1',
|
||||
'vuln-xss': 'agent2',
|
||||
'vuln-auth': 'agent3',
|
||||
'vuln-ssrf': 'agent4',
|
||||
'vuln-authz': 'agent5',
|
||||
|
||||
// Phase 4: Exploitation (5 parallel agents - same as vuln counterparts)
|
||||
'exploit-injection': 'playwright-agent1',
|
||||
'exploit-xss': 'playwright-agent2',
|
||||
'exploit-auth': 'playwright-agent3',
|
||||
'exploit-ssrf': 'playwright-agent4',
|
||||
'exploit-authz': 'playwright-agent5',
|
||||
'exploit-injection': 'agent1',
|
||||
'exploit-xss': 'agent2',
|
||||
'exploit-auth': 'agent3',
|
||||
'exploit-ssrf': 'agent4',
|
||||
'exploit-authz': 'agent5',
|
||||
|
||||
// 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',
|
||||
// Phase 5: Reporting
|
||||
'report-executive': 'agent3',
|
||||
});
|
||||
|
||||
// Direct agent-to-validator mapping - much simpler than pattern matching
|
||||
|
||||
@@ -34,12 +34,7 @@ export const ALL_AGENTS = [
|
||||
*/
|
||||
export type AgentName = (typeof ALL_AGENTS)[number];
|
||||
|
||||
export type PlaywrightAgent =
|
||||
| 'playwright-agent1'
|
||||
| 'playwright-agent2'
|
||||
| 'playwright-agent3'
|
||||
| 'playwright-agent4'
|
||||
| 'playwright-agent5';
|
||||
export type PlaywrightSession = 'agent1' | 'agent2' | 'agent3' | 'agent4' | 'agent5';
|
||||
|
||||
import type { ActivityLogger } from './activity-logger.js';
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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.
|
||||
*/
|
||||
|
||||
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
|
||||
*/
|
||||
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,6 +12,7 @@ export * from './activity-logger.js';
|
||||
export * from './agents.js';
|
||||
export * from './audit.js';
|
||||
export * from './config.js';
|
||||
export * from './deliverables.js';
|
||||
export * from './errors.js';
|
||||
export * from './metrics.js';
|
||||
export * from './result.js';
|
||||
|
||||
Reference in New Issue
Block a user