diff --git a/src/ai/message-handlers.ts b/src/ai/message-handlers.ts index e60ddb5..78068ad 100644 --- a/src/ai/message-handlers.ts +++ b/src/ai/message-handlers.ts @@ -19,9 +19,9 @@ import { } from './output-formatters.js'; import type { AuditLogger } from './audit-logger.js'; import type { ProgressManager } from './progress-manager.js'; +import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk'; import type { AssistantMessage, - SDKAssistantMessageError, ResultMessage, ToolUseMessage, ToolResultMessage, diff --git a/src/ai/types.ts b/src/ai/types.ts index af1a6f6..ee98bdf 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -6,6 +6,8 @@ // Type definitions for Claude executor message processing pipeline +import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk'; + export interface ExecutionContext { isParallelExecution: boolean; useCleanOutput: boolean; @@ -51,14 +53,6 @@ export interface ContentBlock { text?: string; } -export type SDKAssistantMessageError = - | 'authentication_failed' - | 'billing_error' - | 'rate_limit' - | 'invalid_request' - | 'server_error' - | 'max_output_tokens' - | 'unknown'; export interface AssistantMessage { type: 'assistant'; diff --git a/src/audit/workflow-logger.ts b/src/audit/workflow-logger.ts index f01b80b..67a604c 100644 --- a/src/audit/workflow-logger.ts +++ b/src/audit/workflow-logger.ts @@ -311,6 +311,24 @@ export class WorkflowLogger { await this.logStream.write(line); } + /** + * Format a pipe-delimited error string into indented multi-line display. + * + * Input: "phase context|ErrorType|message|Hint: ..." + * Output: "Error: phase context\n ErrorType\n ..." + */ + private formatErrorBlock(errorString: string): string { + const segments = errorString.split('|'); + const label = 'Error: '; + const indent = ' '.repeat(label.length); + + const lines = segments.map((segment, i) => + i === 0 ? `${label}${segment.trim()}` : `${indent}${segment.trim()}` + ); + + return lines.join('\n') + '\n'; + } + /** * Log workflow completion with full summary */ @@ -330,7 +348,7 @@ export class WorkflowLogger { await this.logStream.write(`Agents: ${summary.completedAgents.length} completed\n`); if (summary.error) { - await this.logStream.write(`Error: ${summary.error}\n`); + await this.logStream.write(this.formatErrorBlock(summary.error)); } await this.logStream.write(`\n`); diff --git a/src/services/error-handling.ts b/src/services/error-handling.ts index 2f50d86..1c9c75c 100644 --- a/src/services/error-handling.ts +++ b/src/services/error-handling.ts @@ -148,6 +148,16 @@ function classifyByErrorCode( case ErrorCode.AGENT_EXECUTION_FAILED: return { type: 'AgentExecutionError', retryable: retryableFromError }; + // Preflight validation errors + case ErrorCode.REPO_NOT_FOUND: + return { type: 'ConfigurationError', retryable: false }; + + case ErrorCode.AUTH_FAILED: + return { type: 'AuthenticationError', retryable: false }; + + case ErrorCode.BILLING_ERROR: + return { type: 'BillingError', retryable: true }; + default: // Unknown code - fall through to string matching return { type: 'UnknownError', retryable: retryableFromError }; diff --git a/src/services/preflight.ts b/src/services/preflight.ts new file mode 100644 index 0000000..95d6a08 --- /dev/null +++ b/src/services/preflight.ts @@ -0,0 +1,253 @@ +// 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. + +/** + * Preflight Validation Service + * + * Runs cheap, fast checks before any agent execution begins. + * Catches configuration and credential problems early, saving + * time and API costs compared to failing mid-pipeline. + * + * Checks run sequentially, cheapest first: + * 1. Repository path exists and contains .git + * 2. Config file parses and validates (if provided) + * 3. Credentials validate via Claude Agent SDK query (API key, OAuth, or router mode) + */ + +import fs from 'fs/promises'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import type { SDKAssistantMessageError } from '@anthropic-ai/claude-agent-sdk'; +import { PentestError, isRetryableError } from './error-handling.js'; +import { ErrorCode } from '../types/errors.js'; +import { type Result, ok, err } from '../types/result.js'; +import { parseConfig } from '../config-parser.js'; +import type { ActivityLogger } from '../types/activity-logger.js'; + +// === Repository Validation === + +async function validateRepo( + repoPath: string, + logger: ActivityLogger +): Promise> { + logger.info('Checking repository path...', { repoPath }); + + // 1. Check repo directory exists + try { + const stats = await fs.stat(repoPath); + if (!stats.isDirectory()) { + return err( + new PentestError( + `Repository path is not a directory: ${repoPath}`, + 'config', + false, + { repoPath }, + ErrorCode.REPO_NOT_FOUND + ) + ); + } + } catch { + return err( + new PentestError( + `Repository path does not exist: ${repoPath}`, + 'config', + false, + { repoPath }, + ErrorCode.REPO_NOT_FOUND + ) + ); + } + + // 2. Check .git directory exists + try { + const gitStats = await fs.stat(`${repoPath}/.git`); + if (!gitStats.isDirectory()) { + return err( + new PentestError( + `Not a git repository (no .git directory): ${repoPath}`, + 'config', + false, + { repoPath }, + ErrorCode.REPO_NOT_FOUND + ) + ); + } + } catch { + return err( + new PentestError( + `Not a git repository (no .git directory): ${repoPath}`, + 'config', + false, + { repoPath }, + ErrorCode.REPO_NOT_FOUND + ) + ); + } + + logger.info('Repository path OK'); + return ok(undefined); +} + +// === Config Validation === + +async function validateConfig( + configPath: string, + logger: ActivityLogger +): Promise> { + logger.info('Validating configuration file...', { configPath }); + + try { + await parseConfig(configPath); + logger.info('Configuration file OK'); + return ok(undefined); + } catch (error) { + if (error instanceof PentestError) { + return err(error); + } + const message = error instanceof Error ? error.message : String(error); + return err( + new PentestError( + `Configuration validation failed: ${message}`, + 'config', + false, + { configPath }, + ErrorCode.CONFIG_VALIDATION_FAILED + ) + ); + } +} + +// === Credential Validation === + +/** Map SDK error type to a human-readable preflight PentestError. */ +function classifySdkError( + sdkError: SDKAssistantMessageError, + authType: string +): Result { + switch (sdkError) { + case 'authentication_failed': + return err(new PentestError( + `Invalid ${authType}. Check your credentials in .env and try again.`, + 'config', false, { authType, sdkError }, ErrorCode.AUTH_FAILED + )); + case 'billing_error': + return err(new PentestError( + `Anthropic account has a billing issue. Add credits or check your billing dashboard.`, + 'billing', true, { authType, sdkError }, ErrorCode.BILLING_ERROR + )); + case 'rate_limit': + return err(new PentestError( + `Anthropic rate limit or spending cap reached. Wait a few minutes and try again.`, + 'billing', true, { authType, sdkError }, ErrorCode.BILLING_ERROR + )); + case 'server_error': + return err(new PentestError( + `Anthropic API is temporarily unavailable. Try again shortly.`, + 'network', true, { authType, sdkError } + )); + default: + return err(new PentestError( + `${authType} validation failed unexpectedly. Check your credentials in .env.`, + 'config', false, { authType, sdkError }, ErrorCode.AUTH_FAILED + )); + } +} + +/** Validate credentials via a minimal Claude Agent SDK query. */ +async function validateCredentials( + logger: ActivityLogger +): Promise> { + // 1. Router mode — can't validate provider keys, just warn + if (process.env.ANTHROPIC_BASE_URL) { + logger.warn('Router mode detected — skipping API credential validation'); + return ok(undefined); + } + + // 2. Check that at least one credential is present + if (!process.env.ANTHROPIC_API_KEY && !process.env.CLAUDE_CODE_OAUTH_TOKEN) { + return err( + new PentestError( + 'No API credentials found. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env', + 'config', + false, + {}, + ErrorCode.AUTH_FAILED + ) + ); + } + + // 3. Validate via SDK query + const authType = process.env.CLAUDE_CODE_OAUTH_TOKEN ? 'OAuth token' : 'API key'; + logger.info(`Validating ${authType} via SDK...`); + + try { + for await (const message of query({ prompt: 'hi', options: { model: 'claude-haiku-4-5-20251001', maxTurns: 1 } })) { + if (message.type === 'assistant' && message.error) { + return classifySdkError(message.error, authType); + } + if (message.type === 'result') { + break; + } + } + + logger.info(`${authType} OK`); + return ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const retryable = isRetryableError(error instanceof Error ? error : new Error(message)); + + return err( + new PentestError( + retryable + ? `Failed to reach Anthropic API. Check your network connection.` + : `${authType} validation failed: ${message}`, + retryable ? 'network' : 'config', + retryable, + { authType }, + retryable ? undefined : ErrorCode.AUTH_FAILED + ) + ); + } +} + +// === Preflight Orchestrator === + +/** + * Run all preflight checks sequentially (cheapest first). + * + * 1. Repository path exists and contains .git + * 2. Config file parses and validates (if configPath provided) + * 3. Credentials validate (API key, OAuth, or router mode) + * + * Returns on first failure. + */ +export async function runPreflightChecks( + repoPath: string, + configPath: string | undefined, + logger: ActivityLogger +): Promise> { + // 1. Repository check (free — filesystem only) + const repoResult = await validateRepo(repoPath, logger); + if (!repoResult.ok) { + return repoResult; + } + + // 2. Config check (free — filesystem + CPU) + if (configPath) { + const configResult = await validateConfig(configPath, logger); + if (!configResult.ok) { + return configResult; + } + } + + // 3. Credential check (cheap — 1 SDK round-trip) + const credResult = await validateCredentials(logger); + if (!credResult.ok) { + return credResult; + } + + logger.info('All preflight checks passed'); + return ok(undefined); +} diff --git a/src/temporal/activities.ts b/src/temporal/activities.ts index 9d8a03e..78780f0 100644 --- a/src/temporal/activities.ts +++ b/src/temporal/activities.ts @@ -36,6 +36,8 @@ import { AGENTS } from '../session-manager.js'; import { executeGitCommandWithRetry } from '../services/git-manager.js'; import type { ResumeAttempt } from '../audit/metrics-tracker.js'; import { createActivityLogger } from './activity-logger.js'; +import { runPreflightChecks } from '../services/preflight.js'; +import { isErr } from '../types/result.js'; // Max lengths to prevent Temporal protobuf buffer overflow const MAX_ERROR_MESSAGE_LENGTH = 2000; @@ -246,6 +248,72 @@ export async function runReportAgent(input: ActivityInput): Promise { + const startTime = Date.now(); + const attemptNumber = Context.current().info.attempt; + + const heartbeatInterval = setInterval(() => { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + heartbeat({ phase: 'preflight', elapsedSeconds: elapsed, attempt: attemptNumber }); + }, HEARTBEAT_INTERVAL_MS); + + try { + const logger = createActivityLogger(); + logger.info('Running preflight validation...', { attempt: attemptNumber }); + + const result = await runPreflightChecks(input.repoPath, input.configPath, logger); + + if (isErr(result)) { + const classified = classifyErrorForTemporal(result.error); + const message = truncateErrorMessage(result.error.message); + + if (classified.retryable) { + const failure = ApplicationFailure.create({ + message, + type: classified.type, + details: [{ phase: 'preflight', attemptNumber, elapsed: Date.now() - startTime }], + }); + truncateStackTrace(failure); + throw failure; + } else { + const failure = ApplicationFailure.nonRetryable(message, classified.type, [ + { phase: 'preflight', attemptNumber, elapsed: Date.now() - startTime }, + ]); + truncateStackTrace(failure); + throw failure; + } + } + + logger.info('Preflight validation passed'); + } catch (error) { + if (error instanceof ApplicationFailure) { + throw error; + } + + const classified = classifyErrorForTemporal(error); + const rawMessage = error instanceof Error ? error.message : String(error); + const message = truncateErrorMessage(rawMessage); + + const failure = ApplicationFailure.nonRetryable(message, classified.type, [ + { phase: 'preflight', attemptNumber, elapsed: Date.now() - startTime }, + ]); + truncateStackTrace(failure); + throw failure; + } finally { + clearInterval(heartbeatInterval); + } +} + /** * Assemble the final report by concatenating exploitation evidence files. */ diff --git a/src/temporal/workflow-errors.ts b/src/temporal/workflow-errors.ts new file mode 100644 index 0000000..3062080 --- /dev/null +++ b/src/temporal/workflow-errors.ts @@ -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. + +/** + * Workflow error formatting utilities. + * Pure functions with no side effects — safe for Temporal workflow sandbox. + */ + +/** Maps Temporal error type strings to actionable remediation hints. */ +const REMEDIATION_HINTS: Record = { + AuthenticationError: + 'Verify ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env is valid and not expired.', + ConfigurationError: 'Check your CONFIG file path and contents.', + BillingError: + 'Check your Anthropic billing dashboard. Add credits or wait for spending cap reset.', + GitError: 'Check repository path and git state.', + InvalidTargetError: 'Verify the target URL is correct and accessible.', + PermissionError: 'Check file and network permissions.', + ExecutionLimitError: 'Agent exceeded maximum turns or budget. Review prompt complexity.', +}; + +/** + * Walk the .cause chain to find the innermost error with a .type property. + * Temporal wraps ApplicationFailure in ActivityFailure — the useful info is inside. + * + * Uses duck-typing because workflow code cannot import @temporalio/activity types. + */ +function unwrapActivityError(error: unknown): { + message: string; + type: string | null; +} { + let current: unknown = error; + let typed: { message: string; type: string } | null = null; + + while (current instanceof Error) { + if ('type' in current && typeof (current as { type: unknown }).type === 'string') { + typed = { + message: current.message, + type: (current as { type: string }).type, + }; + } + current = (current as { cause?: unknown }).cause; + } + + if (typed) { + return typed; + } + + return { + message: error instanceof Error ? error.message : String(error), + type: null, + }; +} + +/** + * Format a structured error string from workflow catch context. + * Segments are delimited by | for multi-line rendering by WorkflowLogger. + */ +export function formatWorkflowError( + error: unknown, + currentPhase: string | null, + currentAgent: string | null +): string { + const unwrapped = unwrapActivityError(error); + + // Phase context (first segment) + let phaseContext = 'Pipeline failed'; + if (currentPhase && currentAgent && currentPhase !== currentAgent) { + phaseContext = `${currentPhase} failed (agent: ${currentAgent})`; + } else if (currentPhase) { + phaseContext = `${currentPhase} failed`; + } + + const segments: string[] = [phaseContext]; + + if (unwrapped.type) { + segments.push(unwrapped.type); + } + + // Sanitize pipe characters from message to preserve delimiter format + segments.push(unwrapped.message.replaceAll('|', '/')); + + if (unwrapped.type) { + const hint = REMEDIATION_HINTS[unwrapped.type]; + if (hint) { + segments.push(`Hint: ${hint}`); + } + } + + return segments.join('|'); +} diff --git a/src/temporal/workflows.ts b/src/temporal/workflows.ts index 45344d5..4950f6c 100644 --- a/src/temporal/workflows.ts +++ b/src/temporal/workflows.ts @@ -41,10 +41,10 @@ import { type AgentMetrics, type ResumeState, } from './shared.js'; -import type { VulnType } from '../services/queue-validation.js'; -import type { AgentName } from '../types/agents.js'; +import type { AgentName, VulnType } from '../types/agents.js'; import { ALL_AGENTS } from '../types/agents.js'; import { toWorkflowSummary } from './summary-mapper.js'; +import { formatWorkflowError } from './workflow-errors.js'; // Retry configuration for production (long intervals for billing recovery) const PRODUCTION_RETRY = { @@ -86,6 +86,22 @@ const testActs = proxyActivities({ retry: TESTING_RETRY, }); +// Retry configuration for preflight validation (short timeout, few retries) +const PREFLIGHT_RETRY = { + initialInterval: '10 seconds', + maximumInterval: '1 minute', + backoffCoefficient: 2, + maximumAttempts: 3, + nonRetryableErrorTypes: PRODUCTION_RETRY.nonRetryableErrorTypes, +}; + +// Activity proxy for preflight validation (short timeout) +const preflightActs = proxyActivities({ + startToCloseTimeout: '2 minutes', + heartbeatTimeout: '2 minutes', + retry: PREFLIGHT_RETRY, +}); + /** * Compute aggregated metrics from the current pipeline state. * Called on both success and failure to provide partial metrics. @@ -298,6 +314,14 @@ export async function pentestPipelineWorkflow( } try { + // === Preflight Validation === + // Quick sanity checks before committing to expensive agent runs. + // NOT using runSequentialPhase — preflight doesn't produce AgentMetrics. + state.currentPhase = 'preflight'; + state.currentAgent = null; + await preflightActs.runPreflightValidation(activityInput); + log.info('Preflight validation passed'); + // === Phase 1: Pre-Reconnaissance === await runSequentialPhase('pre-recon', 'pre-recon', a.runPreReconAgent); @@ -409,7 +433,7 @@ export async function pentestPipelineWorkflow( } catch (error) { state.status = 'failed'; state.failedAgent = state.currentAgent; - state.error = error instanceof Error ? error.message : String(error); + state.error = formatWorkflowError(error, state.currentPhase, state.currentAgent); state.summary = computeSummary(state); // Log workflow failure summary diff --git a/src/types/errors.ts b/src/types/errors.ts index f67594f..568fa6a 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -39,6 +39,11 @@ export enum ErrorCode { // Validation errors (PentestErrorType: 'validation') DELIVERABLE_NOT_FOUND = 'DELIVERABLE_NOT_FOUND', + + // Preflight validation errors + REPO_NOT_FOUND = 'REPO_NOT_FOUND', + AUTH_FAILED = 'AUTH_FAILED', + BILLING_ERROR = 'BILLING_ERROR', } export type PentestErrorType =