Merge pull request #149 from KeygraphHQ/fix/preflight-validation

feat: add preflight validation phase with structured error reporting
This commit is contained in:
ezl-keygraph
2026-02-20 21:50:31 +05:30
committed by GitHub
9 changed files with 479 additions and 13 deletions
+1 -1
View File
@@ -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,
+2 -8
View File
@@ -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';
+19 -1
View File
@@ -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`);
+10
View File
@@ -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 };
+253
View File
@@ -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<Result<void, PentestError>> {
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<Result<void, PentestError>> {
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<void, PentestError> {
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<Result<void, PentestError>> {
// 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<Result<void, PentestError>> {
// 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);
}
+68
View File
@@ -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<AgentMetrics
return runAgentActivity('report', input);
}
/**
* Preflight validation activity.
*
* Runs cheap checks before any agent execution:
* 1. Repository path exists with .git
* 2. Config file validates (if provided)
* 3. Credential validation (API key, OAuth, or router mode)
*
* NOT using runAgentActivity — preflight doesn't run an agent via the SDK.
*/
export async function runPreflightValidation(input: ActivityInput): Promise<void> {
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.
*/
+94
View File
@@ -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<string, string> = {
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('|');
}
+27 -3
View File
@@ -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<typeof activities>({
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<typeof activities>({
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
+5
View File
@@ -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 =