diff --git a/Dockerfile b/Dockerfile index c2d7193..75e3205 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,6 +83,7 @@ RUN apk update && apk add --no-cache \ bash \ curl \ ca-certificates \ + shadow \ # Network libraries (runtime) libpcap \ # Security tools diff --git a/apps/cli/src/commands/start.ts b/apps/cli/src/commands/start.ts index 129477e..22e74d3 100644 --- a/apps/cli/src/commands/start.ts +++ b/apps/cli/src/commands/start.ts @@ -7,6 +7,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js'; import { buildEnvFlags, isRouterConfigured, loadEnv, validateCredentials } from '../env.js'; @@ -68,7 +69,10 @@ export async function start(args: StartArgs): Promise { args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`; // 9. Create writable overlay directories (mounted over :ro repo paths inside container) + // Workspace dir must be 0o777 so the container user (UID 1001) can create audit subdirs const workspacePath = path.join(workspacesDir, workspace); + fs.mkdirSync(workspacePath, { recursive: true }); + fs.chmodSync(workspacePath, 0o777); for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) { const dirPath = path.join(workspacePath, dir); fs.mkdirSync(dirPath, { recursive: true }); @@ -76,9 +80,11 @@ export async function start(args: StartArgs): Promise { } // 10. Pre-create overlay mount points (Linux :ro mounts can't auto-create them) - const shannonDir = path.join(repo.hostPath, '.shannon'); - for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) { - fs.mkdirSync(path.join(shannonDir, dir), { recursive: true }); + if (os.platform() === 'linux') { + const shannonDir = path.join(repo.hostPath, '.shannon'); + for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) { + fs.mkdirSync(path.join(shannonDir, dir), { recursive: true }); + } } const credentialsPath = getCredentialsPath(); diff --git a/apps/cli/src/env.ts b/apps/cli/src/env.ts index 71c2f0e..4241ee1 100644 --- a/apps/cli/src/env.ts +++ b/apps/cli/src/env.ts @@ -110,8 +110,6 @@ export function validateCredentials(): CredentialValidation { return { valid: true, mode: 'oauth' }; } if (isCustomBaseUrlConfigured()) { - // Set auth token as API key so the SDK can initialize - process.env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_AUTH_TOKEN; return { valid: true, mode: 'custom-base-url' }; } if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') { @@ -154,8 +152,6 @@ export function validateCredentials(): CredentialValidation { return { valid: true, mode: 'vertex' }; } if (isRouterConfigured()) { - // Set a placeholder so the worker doesn't reject the missing key - process.env.ANTHROPIC_API_KEY = 'router-mode'; return { valid: true, mode: 'router' }; } diff --git a/apps/worker/package.json b/apps/worker/package.json index 954ffd4..c0bb627 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -3,6 +3,16 @@ "version": "0.0.0", "private": true, "type": "module", + "exports": { + "./interfaces": "./dist/interfaces/index.js", + "./types": "./dist/types/index.js", + "./types/config": "./dist/types/config.js", + "./types/agents": "./dist/types/agents.js", + "./pipeline": "./dist/temporal/pipeline.js", + "./activities": "./dist/temporal/activities.js", + "./services": "./dist/services/index.js", + "./config": "./dist/config-parser.js" + }, "scripts": { "build": "tsc", "check": "tsc --noEmit", diff --git a/apps/worker/src/ai/claude-executor.ts b/apps/worker/src/ai/claude-executor.ts index fbc7b8e..1a9454c 100644 --- a/apps/worker/src/ai/claude-executor.ts +++ b/apps/worker/src/ai/claude-executor.ts @@ -9,6 +9,7 @@ import { type JsonSchemaOutputFormat, query } from '@anthropic-ai/claude-agent-sdk'; import { fs, path } from 'zx'; import type { AuditSession } from '../audit/index.js'; +import { deliverablesDir } from '../paths.js'; import { isRetryableError, PentestError } from '../services/error-handling.js'; import { AGENT_VALIDATORS } from '../session-manager.js'; import type { ActivityLogger } from '../types/activity-logger.js'; @@ -72,7 +73,7 @@ async function writeErrorLog( }, duration, }; - const logPath = path.join(sourceDir, '.shannon', 'deliverables', 'error.log'); + const logPath = path.join(deliverablesDir(sourceDir), 'error.log'); await fs.appendFile(logPath, `${JSON.stringify(errorLog)}\n`); } catch { // Best-effort error log writing - don't propagate failures @@ -88,8 +89,8 @@ export async function validateAgentOutput( logger.info(`Validating ${agentName} agent output`); try { - // Check if agent completed successfully - if (!result.success || !result.result) { + // Check if agent completed successfully (text result OR structured output) + if (!result.success || (!result.result && result.structuredOutput === undefined)) { logger.error('Validation failed: Agent execution was unsuccessful'); return false; } @@ -134,6 +135,9 @@ export async function runClaudePrompt( logger: ActivityLogger, modelTier: ModelTier = 'medium', outputFormat?: JsonSchemaOutputFormat, + apiKey?: string, + deliverablesSubdir?: string, + providerConfig?: import('../types/config.js').ProviderConfig, ): Promise { // 1. Initialize timing and prompt const timer = new Timer(`agent-${description.toLowerCase().replace(/\s+/g, '-')}`); @@ -152,23 +156,55 @@ export async function runClaudePrompt( // 3. Build env vars to pass to SDK subprocesses const sdkEnv: Record = { CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || '64000', - PLAYWRIGHT_MCP_OUTPUT_DIR: path.join(sourceDir, '.shannon', '.playwright-cli'), + PLAYWRIGHT_MCP_OUTPUT_DIR: deliverablesSubdir + ? path.join(sourceDir, path.dirname(deliverablesSubdir), '.playwright-cli') + : path.join(sourceDir, '.shannon', '.playwright-cli'), + // apiKey from ContainerConfig takes precedence over process.env + ...(apiKey && { ANTHROPIC_API_KEY: apiKey }), + // Deliverables subdir for save-deliverable CLI tool + ...(deliverablesSubdir && { SHANNON_DELIVERABLES_SUBDIR: deliverablesSubdir }), }; + + // 3a. Apply structured provider config directly to sdkEnv (no process.env mutation) + if (providerConfig) { + switch (providerConfig.providerType) { + case 'bedrock': + sdkEnv.CLAUDE_CODE_USE_BEDROCK = '1'; + if (providerConfig.awsRegion) sdkEnv.AWS_REGION = providerConfig.awsRegion; + if (providerConfig.awsAccessKeyId) sdkEnv.AWS_ACCESS_KEY_ID = providerConfig.awsAccessKeyId; + if (providerConfig.awsSecretAccessKey) sdkEnv.AWS_SECRET_ACCESS_KEY = providerConfig.awsSecretAccessKey; + break; + case 'vertex': + sdkEnv.CLAUDE_CODE_USE_VERTEX = '1'; + if (providerConfig.gcpRegion) sdkEnv.CLOUD_ML_REGION = providerConfig.gcpRegion; + if (providerConfig.gcpProjectId) sdkEnv.ANTHROPIC_VERTEX_PROJECT_ID = providerConfig.gcpProjectId; + if (providerConfig.gcpCredentialsPath) sdkEnv.GOOGLE_APPLICATION_CREDENTIALS = providerConfig.gcpCredentialsPath; + break; + case 'litellm_router': + if (providerConfig.baseUrl) sdkEnv.ANTHROPIC_BASE_URL = providerConfig.baseUrl; + if (providerConfig.authToken) sdkEnv.ANTHROPIC_AUTH_TOKEN = providerConfig.authToken; + if (providerConfig.routerDefault) sdkEnv.ROUTER_DEFAULT = providerConfig.routerDefault; + break; + default: + // 'anthropic_api' or unset — apiKey already handled above + if (providerConfig.apiKey && !apiKey) sdkEnv.ANTHROPIC_API_KEY = providerConfig.apiKey; + break; + } + } + + // 3b. Passthrough env vars not already set by providerConfig or apiKey const passthroughVars = [ - 'ANTHROPIC_API_KEY', + ...(!sdkEnv.ANTHROPIC_API_KEY ? ['ANTHROPIC_API_KEY'] : []), 'CLAUDE_CODE_OAUTH_TOKEN', - 'ANTHROPIC_BASE_URL', - 'ANTHROPIC_AUTH_TOKEN', - 'CLAUDE_CODE_USE_BEDROCK', - 'AWS_REGION', + ...(!sdkEnv.ANTHROPIC_BASE_URL ? ['ANTHROPIC_BASE_URL'] : []), + ...(!sdkEnv.ANTHROPIC_AUTH_TOKEN ? ['ANTHROPIC_AUTH_TOKEN'] : []), + ...(!sdkEnv.CLAUDE_CODE_USE_BEDROCK ? ['CLAUDE_CODE_USE_BEDROCK'] : []), + ...(!sdkEnv.AWS_REGION ? ['AWS_REGION'] : []), 'AWS_BEARER_TOKEN_BEDROCK', - 'CLAUDE_CODE_USE_VERTEX', - 'CLOUD_ML_REGION', - 'ANTHROPIC_VERTEX_PROJECT_ID', - 'GOOGLE_APPLICATION_CREDENTIALS', - 'ANTHROPIC_SMALL_MODEL', - 'ANTHROPIC_MEDIUM_MODEL', - 'ANTHROPIC_LARGE_MODEL', + ...(!sdkEnv.CLAUDE_CODE_USE_VERTEX ? ['CLAUDE_CODE_USE_VERTEX'] : []), + ...(!sdkEnv.CLOUD_ML_REGION ? ['CLOUD_ML_REGION'] : []), + ...(!sdkEnv.ANTHROPIC_VERTEX_PROJECT_ID ? ['ANTHROPIC_VERTEX_PROJECT_ID'] : []), + ...(!sdkEnv.GOOGLE_APPLICATION_CREDENTIALS ? ['GOOGLE_APPLICATION_CREDENTIALS'] : []), 'HOME', 'PATH', 'PLAYWRIGHT_MCP_EXECUTABLE_PATH', @@ -181,8 +217,10 @@ export async function runClaudePrompt( } // 4. Configure SDK options + // Model override from providerConfig takes precedence over env-based resolveModel + const model = providerConfig?.modelOverrides?.[modelTier] ?? resolveModel(modelTier); const options = { - model: resolveModel(modelTier), + model, maxTurns: 10_000, cwd: sourceDir, permissionMode: 'bypassPermissions' as const, diff --git a/apps/worker/src/audit/audit-session.ts b/apps/worker/src/audit/audit-session.ts index e434477..fea8825 100644 --- a/apps/worker/src/audit/audit-session.ts +++ b/apps/worker/src/audit/audit-session.ts @@ -202,7 +202,7 @@ export class AuditSession { /** * Update session status */ - async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed'): Promise { + async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed' | 'cancelled'): Promise { await this.ensureInitialized(); const unlock = await sessionMutex.lock(this.sessionId); diff --git a/apps/worker/src/audit/metrics-tracker.ts b/apps/worker/src/audit/metrics-tracker.ts index 1ce9ea4..9bad57c 100644 --- a/apps/worker/src/audit/metrics-tracker.ts +++ b/apps/worker/src/audit/metrics-tracker.ts @@ -57,7 +57,7 @@ interface SessionData { id: string; webUrl: string; repoPath?: string; - status: 'in-progress' | 'completed' | 'failed'; + status: 'in-progress' | 'completed' | 'failed' | 'cancelled'; createdAt: string; completedAt?: string; originalWorkflowId?: string; // First workflow that created this workspace @@ -232,12 +232,12 @@ export class MetricsTracker { /** * Update session status */ - async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed'): Promise { + async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed' | 'cancelled'): Promise { if (!this.data) return; this.data.session.status = status; - if (status === 'completed' || status === 'failed') { + if (status === 'completed' || status === 'failed' || status === 'cancelled') { this.data.session.completedAt = formatTimestamp(); } diff --git a/apps/worker/src/audit/workflow-logger.ts b/apps/worker/src/audit/workflow-logger.ts index 75cc54d..d8bad90 100644 --- a/apps/worker/src/audit/workflow-logger.ts +++ b/apps/worker/src/audit/workflow-logger.ts @@ -30,7 +30,7 @@ export interface AgentMetricsSummary { } export interface WorkflowSummary { - status: 'completed' | 'failed'; + status: 'completed' | 'failed' | 'cancelled'; totalDurationMs: number; totalCostUsd: number; completedAgents: string[]; diff --git a/apps/worker/src/config-parser.ts b/apps/worker/src/config-parser.ts index 437eefc..f6bfd15 100644 --- a/apps/worker/src/config-parser.ts +++ b/apps/worker/src/config-parser.ts @@ -258,6 +258,54 @@ export const parseConfig = async (configPath: string): Promise => { } }; +/** + * Parse a raw YAML string into a validated Config object. + * + * Same validation as parseConfig but accepts a string instead of a file path. + * Used when config YAML is passed inline (e.g., from a parent workflow). + */ +export const parseConfigYAML = (yamlContent: string): Config => { + if (!yamlContent.trim()) { + throw new PentestError( + 'Configuration YAML string is empty', + 'config', + false, + {}, + ErrorCode.CONFIG_VALIDATION_FAILED, + ); + } + + let config: unknown; + try { + config = yaml.load(yamlContent, { + schema: yaml.FAILSAFE_SCHEMA, + json: false, + }); + } catch (yamlError) { + const errMsg = yamlError instanceof Error ? yamlError.message : String(yamlError); + throw new PentestError( + `YAML parsing failed: ${errMsg}`, + 'config', + false, + { originalError: errMsg }, + ErrorCode.CONFIG_PARSE_ERROR, + ); + } + + if (config === null || config === undefined) { + throw new PentestError( + 'Configuration YAML resulted in null/undefined after parsing', + 'config', + false, + {}, + ErrorCode.CONFIG_PARSE_ERROR, + ); + } + + validateConfig(config as Config); + return config as Config; +}; + const validateConfig = (config: Config): void => { if (!config || typeof config !== 'object') { throw new PentestError( diff --git a/apps/worker/src/interfaces/checkpoint-provider.ts b/apps/worker/src/interfaces/checkpoint-provider.ts new file mode 100644 index 0000000..e6a0161 --- /dev/null +++ b/apps/worker/src/interfaces/checkpoint-provider.ts @@ -0,0 +1,26 @@ +/** + * CheckpointProvider — injectable interface for external state persistence. + * + * Called after each agent completes to allow external progress tracking. + * During the concurrent vulnerability-exploitation phase, 5 pipelines run + * in parallel — onAgentComplete fires per-agent for granular progress. + * + * Default: no-op. + */ + +import type { PipelineState } from '../temporal/shared.js'; + +export interface CheckpointProvider { + onAgentComplete( + agentName: string, + phase: string, + state: PipelineState, + ): Promise; +} + +/** Default no-op implementation — no external checkpointing. */ +export class NoOpCheckpointProvider implements CheckpointProvider { + async onAgentComplete(): Promise { + // No-op + } +} diff --git a/apps/worker/src/interfaces/findings-provider.ts b/apps/worker/src/interfaces/findings-provider.ts new file mode 100644 index 0000000..f1e01e7 --- /dev/null +++ b/apps/worker/src/interfaces/findings-provider.ts @@ -0,0 +1,26 @@ +/** + * FindingsProvider — injectable interface for external findings integration. + * + * Allows external security data (SAST, SCA, secrets, etc.) to be merged + * into the exploitation pipeline between vulnerability analysis and exploitation. + * + * Default: no-op returning { mergedCount: 0 }. + */ + +import type { ActivityInput } from '../temporal/activities.js'; +import type { VulnType } from '../types/agents.js'; + +export interface FindingsProvider { + mergeFindingsIntoQueue( + repoPath: string, + vulnType: VulnType, + input: ActivityInput, + ): Promise<{ mergedCount: number }>; +} + +/** Default no-op implementation — no external findings to merge. */ +export class NoOpFindingsProvider implements FindingsProvider { + async mergeFindingsIntoQueue(): Promise<{ mergedCount: number }> { + return { mergedCount: 0 }; + } +} diff --git a/apps/worker/src/interfaces/index.ts b/apps/worker/src/interfaces/index.ts new file mode 100644 index 0000000..c73e3bc --- /dev/null +++ b/apps/worker/src/interfaces/index.ts @@ -0,0 +1,11 @@ +/** + * Injectable interfaces for extending the pentest pipeline. + * + * All interfaces have default no-op implementations. + * Consumers can provide alternate implementations via the DI container. + */ + +export type { CheckpointProvider } from './checkpoint-provider.js'; +export { NoOpCheckpointProvider } from './checkpoint-provider.js'; +export type { FindingsProvider } from './findings-provider.js'; +export { NoOpFindingsProvider } from './findings-provider.js'; diff --git a/apps/worker/src/paths.ts b/apps/worker/src/paths.ts index dc9b71a..70380ae 100644 --- a/apps/worker/src/paths.ts +++ b/apps/worker/src/paths.ts @@ -9,6 +9,21 @@ const WORKER_ROOT = path.resolve(import.meta.dirname, '..'); export const PROMPTS_DIR = path.join(WORKER_ROOT, 'prompts'); export const CONFIGS_DIR = path.join(WORKER_ROOT, 'configs'); +/** Default deliverables subdirectory relative to repoPath */ +export const DEFAULT_DELIVERABLES_SUBDIR = '.shannon/deliverables'; + +/** Default audit log directory */ +export const DEFAULT_AUDIT_DIR = './workspaces'; + +/** + * Resolve the deliverables directory for a given repoPath and optional subdir override. + * @param repoPath - Absolute path to the target repository + * @param subdir - Subdirectory relative to repoPath (default: '.shannon/deliverables') + */ +export function deliverablesDir(repoPath: string, subdir: string = DEFAULT_DELIVERABLES_SUBDIR): string { + return path.join(repoPath, ...subdir.split('/')); +} + /** * Repository root — walk up from WORKER_ROOT looking for pnpm-workspace.yaml. * Falls back to two levels up (apps/worker/ → repo root) if not found. diff --git a/apps/worker/src/scripts/save-deliverable.ts b/apps/worker/src/scripts/save-deliverable.ts index e0400fa..8459abe 100644 --- a/apps/worker/src/scripts/save-deliverable.ts +++ b/apps/worker/src/scripts/save-deliverable.ts @@ -52,7 +52,8 @@ function parseArgs(argv: string[]): ParsedArgs { // === File Operations === function saveDeliverableFile(targetDir: string, filename: string, content: string): string { - const deliverablesDir = join(targetDir, '.shannon', 'deliverables'); + const subdir = process.env.SHANNON_DELIVERABLES_SUBDIR || '.shannon/deliverables'; + const deliverablesDir = join(targetDir, ...subdir.split('/')); const filepath = join(deliverablesDir, filename); try { diff --git a/apps/worker/src/services/agent-execution.ts b/apps/worker/src/services/agent-execution.ts index 50bd697..e45d22f 100644 --- a/apps/worker/src/services/agent-execution.ts +++ b/apps/worker/src/services/agent-execution.ts @@ -46,8 +46,13 @@ export interface AgentExecutionInput { repoPath: string; deliverablesPath: string; configPath?: string | undefined; + configData?: import('../types/config.js').DistributedConfig | undefined; + configYAML?: string | undefined; pipelineTestingMode?: boolean | undefined; attemptNumber: number; + apiKey?: string | undefined; + promptDir?: string | undefined; + providerConfig?: import('../types/config.js').ProviderConfig | undefined; } interface FailAgentOpts { @@ -90,10 +95,10 @@ export class AgentExecutionService { auditSession: AuditSession, logger: ActivityLogger, ): Promise> { - const { webUrl, repoPath, deliverablesPath, configPath, pipelineTestingMode = false, attemptNumber } = input; + const { webUrl, repoPath, deliverablesPath, configPath, configData, configYAML, pipelineTestingMode = false, attemptNumber, apiKey, promptDir, providerConfig } = input; - // 1. Load config (if provided) - const configResult = await this.configLoader.loadOptional(configPath); + // 1. Load config (pre-parsed configData → raw YAML → file path) + const configResult = await this.configLoader.loadOptional(configPath, configData, configYAML); if (isErr(configResult)) { return configResult; } @@ -103,7 +108,7 @@ export class AgentExecutionService { const promptTemplate = AGENTS[agentName].promptTemplate; let prompt: string; try { - prompt = await loadPrompt(promptTemplate, { webUrl, repoPath }, distributedConfig, pipelineTestingMode, logger); + prompt = await loadPrompt(promptTemplate, { webUrl, repoPath }, distributedConfig, pipelineTestingMode, logger, promptDir); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return err( @@ -148,6 +153,9 @@ export class AgentExecutionService { logger, AGENTS[agentName].modelTier, outputFormat, + apiKey, + path.relative(repoPath, deliverablesPath), + providerConfig, ); // 6. Spending cap check - defense-in-depth @@ -184,15 +192,14 @@ export class AgentExecutionService { // 8. Write structured output to disk (vuln agents only) const queueFilename = getQueueFilename(agentName); if (result.structuredOutput !== undefined && queueFilename) { - const deliverablesDir = path.join(repoPath, '.shannon', 'deliverables'); - await fs.ensureDir(deliverablesDir); - const queuePath = path.join(deliverablesDir, queueFilename); + await fs.ensureDir(deliverablesPath); + const queuePath = path.join(deliverablesPath, queueFilename); await fs.writeFile(queuePath, JSON.stringify(result.structuredOutput, null, 2), 'utf8'); logger.info(`Wrote structured output queue to ${queueFilename}`); } // 9. Validate output - const validationPassed = await validateAgentOutput(result, agentName, repoPath, logger); + const validationPassed = await validateAgentOutput(result, agentName, deliverablesPath, logger); if (!validationPassed) { return this.failAgent(agentName, deliverablesPath, auditSession, logger, { attemptNumber, diff --git a/apps/worker/src/services/config-loader.ts b/apps/worker/src/services/config-loader.ts index 709a8b8..7f2200c 100644 --- a/apps/worker/src/services/config-loader.ts +++ b/apps/worker/src/services/config-loader.ts @@ -11,7 +11,7 @@ * Pure service with no Temporal dependencies. */ -import { distributeConfig, parseConfig } from '../config-parser.js'; +import { distributeConfig, parseConfig, parseConfigYAML } from '../config-parser.js'; import type { DistributedConfig } from '../types/config.js'; import { ErrorCode } from '../types/errors.js'; import { err, ok, type Result } from '../types/result.js'; @@ -60,11 +60,31 @@ export class ConfigLoaderService { /** * Load config if path is provided, otherwise return null config. + * If configData is provided (pre-parsed), returns it directly without file I/O. * * @param configPath - Optional path to the YAML configuration file + * @param configData - Optional pre-parsed config (bypasses file loading) * @returns Result containing DistributedConfig (or null) on success, PentestError on failure */ - async loadOptional(configPath: string | undefined): Promise> { + async loadOptional( + configPath: string | undefined, + configData?: DistributedConfig, + configYAML?: string, + ): Promise> { + if (configData) { + return ok(configData); + } + if (configYAML) { + try { + const config = parseConfigYAML(configYAML); + return ok(distributeConfig(config)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return err( + new PentestError(`Failed to parse config YAML: ${errorMessage}`, 'config', false, { originalError: errorMessage }, ErrorCode.CONFIG_PARSE_ERROR), + ); + } + } if (!configPath) { return ok(null); } diff --git a/apps/worker/src/services/container.ts b/apps/worker/src/services/container.ts index b494ebe..05265a7 100644 --- a/apps/worker/src/services/container.ts +++ b/apps/worker/src/services/container.ts @@ -18,6 +18,11 @@ */ import type { SessionMetadata } from '../audit/utils.js'; +import type { CheckpointProvider } from '../interfaces/checkpoint-provider.js'; +import { NoOpCheckpointProvider } from '../interfaces/checkpoint-provider.js'; +import type { FindingsProvider } from '../interfaces/findings-provider.js'; +import { NoOpFindingsProvider } from '../interfaces/findings-provider.js'; +import type { ContainerConfig } from '../types/config.js'; import { AgentExecutionService } from './agent-execution.js'; import { ConfigLoaderService } from './config-loader.js'; import { ExploitationCheckerService } from './exploitation-checker.js'; @@ -32,6 +37,9 @@ import { ExploitationCheckerService } from './exploitation-checker.js'; */ export interface ContainerDependencies { readonly sessionMetadata: SessionMetadata; + readonly config: ContainerConfig; + readonly findingsProvider?: FindingsProvider; + readonly checkpointProvider?: CheckpointProvider; } /** @@ -45,17 +53,25 @@ export interface ContainerDependencies { */ export class Container { readonly sessionMetadata: SessionMetadata; + readonly config: ContainerConfig; readonly agentExecution: AgentExecutionService; readonly configLoader: ConfigLoaderService; readonly exploitationChecker: ExploitationCheckerService; + readonly findingsProvider: FindingsProvider; + readonly checkpointProvider: CheckpointProvider; constructor(deps: ContainerDependencies) { this.sessionMetadata = deps.sessionMetadata; + this.config = deps.config; // Wire services with explicit constructor injection this.configLoader = new ConfigLoaderService(); this.exploitationChecker = new ExploitationCheckerService(); this.agentExecution = new AgentExecutionService(this.configLoader); + + // Wire providers with default no-ops when not provided + this.findingsProvider = deps.findingsProvider ?? new NoOpFindingsProvider(); + this.checkpointProvider = deps.checkpointProvider ?? new NoOpCheckpointProvider(); } } @@ -65,6 +81,12 @@ export class Container { */ const containers = new Map(); +/** Default container config — OSS standalone defaults */ +const DEFAULT_CONFIG: ContainerConfig = { + deliverablesSubdir: '.shannon/deliverables', + auditDir: './workspaces', +}; + /** * Get or create a Container for a workflow. * @@ -73,13 +95,18 @@ const containers = new Map(); * * @param workflowId - Unique workflow identifier * @param sessionMetadata - Session metadata for audit paths + * @param config - Runtime configuration (defaults to OSS standalone config) * @returns Container instance for the workflow */ -export function getOrCreateContainer(workflowId: string, sessionMetadata: SessionMetadata): Container { +export function getOrCreateContainer( + workflowId: string, + sessionMetadata: SessionMetadata, + config: ContainerConfig = DEFAULT_CONFIG, +): Container { let container = containers.get(workflowId); if (!container) { - container = new Container({ sessionMetadata }); + container = new Container({ sessionMetadata, config }); containers.set(workflowId, container); } diff --git a/apps/worker/src/services/index.ts b/apps/worker/src/services/index.ts index e7c41b8..2412f96 100644 --- a/apps/worker/src/services/index.ts +++ b/apps/worker/src/services/index.ts @@ -16,7 +16,7 @@ export { AgentExecutionService } from './agent-execution.js'; export { ConfigLoaderService } from './config-loader.js'; export type { ContainerDependencies } from './container.js'; -export { Container, getOrCreateContainer, removeContainer } from './container.js'; +export { Container, getContainer, getOrCreateContainer, removeContainer } from './container.js'; export { ExploitationCheckerService } from './exploitation-checker.js'; export { loadPrompt } from './prompt-manager.js'; export { assembleFinalReport, injectModelIntoReport } from './reporting.js'; diff --git a/apps/worker/src/services/preflight.ts b/apps/worker/src/services/preflight.ts index 32e8a7c..01c134a 100644 --- a/apps/worker/src/services/preflight.ts +++ b/apps/worker/src/services/preflight.ts @@ -39,7 +39,7 @@ function isLoopbackAddress(address: string): boolean { // === Repository Validation === -async function validateRepo(repoPath: string, logger: ActivityLogger): Promise> { +async function validateRepo(repoPath: string, logger: ActivityLogger, skipGitCheck?: boolean): Promise> { logger.info('Checking repository path...', { repoPath }); // 1. Check repo directory exists @@ -68,10 +68,22 @@ async function validateRepo(repoPath: string, logger: ActivityLogger): Promise> { +async function validateCredentials(logger: ActivityLogger, apiKey?: string, providerConfig?: import('../types/config.js').ProviderConfig): Promise> { + // 0. If providerConfig is present, credentials are managed by the caller. + // The executor will map providerConfig directly to sdkEnv — no process.env needed. + if (providerConfig) { + logger.info(`Provider config present (type: ${providerConfig.providerType || 'anthropic_api'}) — skipping env-based credential validation`); + return ok(undefined); + } + + // 0b. If apiKey provided via config, set it in env for SDK validation + // This avoids requiring process.env.ANTHROPIC_API_KEY when key is threaded via input + if (apiKey) { + process.env.ANTHROPIC_API_KEY = apiKey; + } // 1. Custom base URL — validate endpoint is reachable via SDK query - if (process.env.ANTHROPIC_BASE_URL) { + if (process.env.ANTHROPIC_BASE_URL && process.env.ANTHROPIC_AUTH_TOKEN) { const baseUrl = process.env.ANTHROPIC_BASE_URL; logger.info(`Validating custom base URL: ${baseUrl}`); @@ -289,7 +305,7 @@ async function validateCredentials(logger: ActivityLogger): Promise> { // 1. Repository check (free — filesystem only) - const repoResult = await validateRepo(repoPath, logger); + const repoResult = await validateRepo(repoPath, logger, skipGitCheck); if (!repoResult.ok) { return repoResult; } @@ -472,8 +491,8 @@ export async function runPreflightChecks( } } - // 3. Credential check (cheap — 1 SDK round-trip) - const credResult = await validateCredentials(logger); + // 3. Credential check (cheap — 1 SDK round-trip, skipped when providerConfig present) + const credResult = await validateCredentials(logger, apiKey, providerConfig); if (!credResult.ok) { return credResult; } diff --git a/apps/worker/src/services/prompt-manager.ts b/apps/worker/src/services/prompt-manager.ts index 3e629c3..19e7efe 100644 --- a/apps/worker/src/services/prompt-manager.ts +++ b/apps/worker/src/services/prompt-manager.ts @@ -23,10 +23,14 @@ interface IncludeReplacement { } // Pure function: Build complete login instructions from config -async function buildLoginInstructions(authentication: Authentication, logger: ActivityLogger): Promise { +async function buildLoginInstructions( + authentication: Authentication, + logger: ActivityLogger, + promptsBaseDir: string = PROMPTS_DIR, +): Promise { try { // 1. Load the login instructions template - const loginInstructionsPath = path.join(PROMPTS_DIR, 'shared', 'login-instructions.txt'); + const loginInstructionsPath = path.join(promptsBaseDir, 'shared', 'login-instructions.txt'); if (!(await fs.pathExists(loginInstructionsPath))) { throw new PentestError('Login instructions template not found', 'filesystem', false, { loginInstructionsPath }); @@ -148,6 +152,7 @@ async function interpolateVariables( variables: PromptVariables, config: DistributedConfig | null = null, logger: ActivityLogger, + promptsBaseDir: string = PROMPTS_DIR, ): Promise { try { if (!template || typeof template !== 'string') { @@ -188,7 +193,7 @@ async function interpolateVariables( // Extract and inject login instructions from config if (config.authentication?.login_flow) { - const loginInstructions = await buildLoginInstructions(config.authentication, logger); + const loginInstructions = await buildLoginInstructions(config.authentication, logger, promptsBaseDir); result = result.replace(/{{LOGIN_INSTRUCTIONS}}/g, loginInstructions); } else { result = result.replace(/{{LOGIN_INSTRUCTIONS}}/g, ''); @@ -223,10 +228,12 @@ export async function loadPrompt( config: DistributedConfig | null = null, pipelineTestingMode: boolean = false, logger: ActivityLogger, + promptDir?: string, ): Promise { try { - // 1. Resolve prompt file path - const promptsDir = pipelineTestingMode ? path.join(PROMPTS_DIR, 'pipeline-testing') : PROMPTS_DIR; + // 1. Resolve prompt file path (promptDir override → default PROMPTS_DIR) + const basePromptsDir = promptDir ?? PROMPTS_DIR; + const promptsDir = pipelineTestingMode ? path.join(basePromptsDir, 'pipeline-testing') : basePromptsDir; const promptPath = path.join(promptsDir, `${promptName}.txt`); if (pipelineTestingMode) { @@ -256,7 +263,7 @@ export async function loadPrompt( template = await processIncludes(template, promptsDir); // 5. Interpolate variables and return final prompt - return await interpolateVariables(template, enhancedVariables, config, logger); + return await interpolateVariables(template, enhancedVariables, config, logger, basePromptsDir); } catch (error) { if (error instanceof PentestError) { throw error; diff --git a/apps/worker/src/services/queue-validation.ts b/apps/worker/src/services/queue-validation.ts index 1aec900..287a476 100644 --- a/apps/worker/src/services/queue-validation.ts +++ b/apps/worker/src/services/queue-validation.ts @@ -5,6 +5,7 @@ // as published by the Free Software Foundation. import { fs, path } from 'zx'; + import type { ExploitationDecision, VulnType } from '../types/agents.js'; import { ErrorCode } from '../types/errors.js'; import { err, ok, type Result } from '../types/result.js'; @@ -133,8 +134,8 @@ const createPaths = (vulnType: VulnType, sourceDir: string): PathsBase | PathsWi return Object.freeze({ vulnType, - deliverable: path.join(sourceDir, '.shannon', 'deliverables', config.deliverable), - queue: path.join(sourceDir, '.shannon', 'deliverables', config.queue), + deliverable: path.join(sourceDir, config.deliverable), + queue: path.join(sourceDir, config.queue), sourceDir, }); }; diff --git a/apps/worker/src/services/reporting.ts b/apps/worker/src/services/reporting.ts index 9e33996..a9c294a 100644 --- a/apps/worker/src/services/reporting.ts +++ b/apps/worker/src/services/reporting.ts @@ -5,6 +5,7 @@ // as published by the Free Software Foundation. import { fs, path } from 'zx'; +import { deliverablesDir } from '../paths.js'; import type { ActivityLogger } from '../types/activity-logger.js'; import { ErrorCode } from '../types/errors.js'; import { PentestError } from './error-handling.js'; @@ -28,7 +29,7 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog const sections: string[] = []; for (const file of deliverableFiles) { - const filePath = path.join(sourceDir, '.shannon', 'deliverables', file.path); + const filePath = path.join(deliverablesDir(sourceDir), file.path); try { if (await fs.pathExists(filePath)) { const content = await fs.readFile(filePath, 'utf8'); @@ -55,12 +56,12 @@ export async function assembleFinalReport(sourceDir: string, logger: ActivityLog } const finalContent = sections.join('\n\n'); - const deliverablesDir = path.join(sourceDir, '.shannon', 'deliverables'); - const finalReportPath = path.join(deliverablesDir, 'comprehensive_security_assessment_report.md'); + const outputDir = deliverablesDir(sourceDir); + const finalReportPath = path.join(outputDir, 'comprehensive_security_assessment_report.md'); try { // Ensure deliverables directory exists - await fs.ensureDir(deliverablesDir); + await fs.ensureDir(outputDir); await fs.writeFile(finalReportPath, finalContent); logger.info(`Final report assembled at ${finalReportPath}`); } catch (error) { @@ -117,7 +118,7 @@ export async function injectModelIntoReport( logger.info(`Injecting model info into report: ${modelStr}`); // 3. Read the final report - const reportPath = path.join(repoPath, '.shannon', 'deliverables', 'comprehensive_security_assessment_report.md'); + const reportPath = path.join(deliverablesDir(repoPath), 'comprehensive_security_assessment_report.md'); if (!(await fs.pathExists(reportPath))) { logger.warn('Final report not found, skipping model injection'); diff --git a/apps/worker/src/session-manager.ts b/apps/worker/src/session-manager.ts index 4073b7f..9b808a1 100644 --- a/apps/worker/src/session-manager.ts +++ b/apps/worker/src/session-manager.ts @@ -5,6 +5,7 @@ // as published by the Free Software Foundation. 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, PlaywrightSession, VulnType } from './types/index.js'; @@ -143,7 +144,7 @@ function createVulnValidator(vulnType: VulnType): AgentValidator { // Factory function for exploit deliverable validators function createExploitValidator(vulnType: VulnType): AgentValidator { return async (sourceDir: string): Promise => { - const evidenceFile = path.join(sourceDir, '.shannon', 'deliverables', `${vulnType}_exploitation_evidence.md`); + const evidenceFile = path.join(sourceDir, `${vulnType}_exploitation_evidence.md`); return await fs.pathExists(evidenceFile); }; } @@ -179,13 +180,13 @@ export const PLAYWRIGHT_SESSION_MAPPING: Record = Obj export const AGENT_VALIDATORS: Record = Object.freeze({ // Pre-reconnaissance agent - validates the code analysis deliverable created by the agent 'pre-recon': async (sourceDir: string): Promise => { - const codeAnalysisFile = path.join(sourceDir, '.shannon', 'deliverables', 'pre_recon_deliverable.md'); + const codeAnalysisFile = path.join(sourceDir, 'pre_recon_deliverable.md'); return await fs.pathExists(codeAnalysisFile); }, // Reconnaissance agent recon: async (sourceDir: string): Promise => { - const reconFile = path.join(sourceDir, '.shannon', 'deliverables', 'recon_deliverable.md'); + const reconFile = path.join(sourceDir, 'recon_deliverable.md'); return await fs.pathExists(reconFile); }, @@ -205,7 +206,7 @@ export const AGENT_VALIDATORS: Record = Object.freeze // Executive report agent report: async (sourceDir: string, logger: ActivityLogger): Promise => { - const reportFile = path.join(sourceDir, '.shannon', 'deliverables', 'comprehensive_security_assessment_report.md'); + const reportFile = path.join(sourceDir, 'comprehensive_security_assessment_report.md'); const reportExists = await fs.pathExists(reportFile); diff --git a/apps/worker/src/temporal/activities.ts b/apps/worker/src/temporal/activities.ts index bca205a..d84afc2 100644 --- a/apps/worker/src/temporal/activities.ts +++ b/apps/worker/src/temporal/activities.ts @@ -22,6 +22,7 @@ import { AuditSession } from '../audit/index.js'; import type { ResumeAttempt } from '../audit/metrics-tracker.js'; import type { SessionMetadata } from '../audit/utils.js'; import type { WorkflowSummary } from '../audit/workflow-logger.js'; +import type { ContainerConfig, ProviderConfig } from '../types/config.js'; import { getContainer, getOrCreateContainer, removeContainer } from '../services/container.js'; import { classifyErrorForTemporal, PentestError } from '../services/error-handling.js'; import { ExploitationCheckerService } from '../services/exploitation-checker.js'; @@ -34,9 +35,10 @@ import type { AgentName } from '../types/agents.js'; import { ALL_AGENTS } from '../types/agents.js'; import { ErrorCode } from '../types/errors.js'; import { isErr } from '../types/result.js'; +import { DEFAULT_DELIVERABLES_SUBDIR, deliverablesDir } from '../paths.js'; import { fileExists, readJson } from '../utils/file-io.js'; import { createActivityLogger } from './activity-logger.js'; -import type { AgentMetrics, ResumeState } from './shared.js'; +import type { AgentMetrics, PipelineState, ResumeState } from './shared.js'; // Max lengths to prevent Temporal protobuf buffer overflow const MAX_ERROR_MESSAGE_LENGTH = 2000; @@ -49,6 +51,9 @@ const HEARTBEAT_INTERVAL_MS = 2000; /** * Input for all agent activities. + * + * Config fields are optional with sensible defaults. When provided, they + * flow through to getOrCreateContainer() for path and credential configuration. */ export interface ActivityInput { webUrl: string; @@ -58,6 +63,16 @@ export interface ActivityInput { pipelineTestingMode?: boolean; workflowId: string; sessionId: string; + + // Config fields — serializable, read by getOrCreateContainer() + configYAML?: string; + apiKey?: string; + deliverablesSubdir?: string; + auditDir?: string; + promptDir?: string; + sastSarifPath?: string; + skipGitCheck?: boolean; + providerConfig?: ProviderConfig; } /** @@ -92,6 +107,19 @@ function buildSessionMetadata(input: ActivityInput): SessionMetadata { }; } +/** + * Build ContainerConfig from ActivityInput, falling back to defaults. + */ +function buildContainerConfig(input: ActivityInput): ContainerConfig { + return { + deliverablesSubdir: input.deliverablesSubdir ?? DEFAULT_DELIVERABLES_SUBDIR, + auditDir: input.auditDir ?? './workspaces', + ...(input.apiKey !== undefined && { apiKey: input.apiKey }), + ...(input.promptDir !== undefined && { promptDir: input.promptDir }), + ...(input.providerConfig !== undefined && { providerConfig: input.providerConfig }), + }; +} + /** * Core activity implementation using services. * @@ -117,7 +145,7 @@ async function runAgentActivity(agentName: AgentName, input: ActivityInput): Pro // 1. Build session metadata and get/create container const sessionMetadata = buildSessionMetadata(input); - const container = getOrCreateContainer(workflowId, sessionMetadata); + const container = getOrCreateContainer(workflowId, sessionMetadata, buildContainerConfig(input)); // 2. Create audit session for THIS agent execution // NOTE: Each agent needs its own AuditSession because AuditSession uses @@ -126,7 +154,7 @@ async function runAgentActivity(agentName: AgentName, input: ActivityInput): Pro await auditSession.initialize(workflowId); // 3. Execute agent via service (throws PentestError on failure) - const deliverablesPath = path.join(repoPath, '.shannon', 'deliverables'); + const deliverablesPath = deliverablesDir(repoPath, container.config.deliverablesSubdir); const endResult = await container.agentExecution.executeOrThrow( agentName, { @@ -136,6 +164,14 @@ async function runAgentActivity(agentName: AgentName, input: ActivityInput): Pro configPath, pipelineTestingMode, attemptNumber, + ...(input.apiKey !== undefined && { apiKey: input.apiKey }), + ...(input.providerConfig !== undefined && { providerConfig: input.providerConfig }), + ...(input.promptDir !== undefined && { + promptDir: path.isAbsolute(input.promptDir) + ? input.promptDir + : path.resolve(process.env.SHANNON_WORKER_ROOT ?? process.cwd(), input.promptDir), + }), + ...(input.configYAML !== undefined && { configYAML: input.configYAML }), }, auditSession, logger, @@ -270,7 +306,7 @@ export async function runPreflightValidation(input: ActivityInput): Promise { - const deliverablesPath = path.join(input.repoPath, '.shannon', 'deliverables'); + const deliverablesPath = deliverablesDir(input.repoPath, input.deliverablesSubdir); await fs.mkdir(deliverablesPath, { recursive: true }); // Check for .git directly inside deliverables, not parent repo's .git @@ -382,7 +418,9 @@ export async function checkExploitationQueue(input: ActivityInput, vulnType: Vul const existingContainer = getContainer(workflowId); const checker = existingContainer?.exploitationChecker ?? new ExploitationCheckerService(); - return checker.checkQueue(vulnType, repoPath, logger); + // Pass deliverablesPath (not repoPath) — validators expect the deliverables directory + const delivPath = deliverablesDir(repoPath, input.deliverablesSubdir); + return checker.checkQueue(vulnType, delivPath, logger); } interface SessionJson { @@ -411,6 +449,7 @@ export async function loadResumeState( workspaceName: string, expectedUrl: string, expectedRepoPath: string, + deliverablesSubdir?: string, ): Promise { // 1. Validate workspace exists const sessionPath = path.join('./workspaces', workspaceName, 'session.json'); @@ -453,7 +492,7 @@ export async function loadResumeState( } const deliverableFilename = AGENTS[agentName].deliverableFilename; - const deliverablePath = `${expectedRepoPath}/.shannon/deliverables/${deliverableFilename}`; + const deliverablePath = path.join(deliverablesDir(expectedRepoPath, deliverablesSubdir), deliverableFilename); const deliverableExists = await fileExists(deliverablePath); if (!deliverableExists) { @@ -487,7 +526,7 @@ export async function loadResumeState( } // 5. Find the most recent checkpoint commit - const deliverablesPath = path.join(expectedRepoPath, '.shannon', 'deliverables'); + const deliverablesPath = deliverablesDir(expectedRepoPath, deliverablesSubdir); const checkpointHash = await findLatestCommit(deliverablesPath, checkpoints); const originalWorkflowId = session.session.originalWorkflowId || session.session.id; @@ -540,8 +579,9 @@ export async function restoreGitCheckpoint( repoPath: string, checkpointHash: string, incompleteAgents: AgentName[], + deliverablesSubdir?: string, ): Promise { - const deliverablesPath = path.join(repoPath, '.shannon', 'deliverables'); + const deliverablesPath = deliverablesDir(repoPath, deliverablesSubdir); const logger = createActivityLogger(); logger.info(`Restoring deliverables to ${checkpointHash}...`); @@ -665,3 +705,36 @@ export async function logWorkflowComplete(input: ActivityInput, summary: Workflo // 6. Clean up container removeContainer(workflowId); } + +/** + * Merge external findings into the exploitation queue for a vulnerability type. + * + * Delegates to the FindingsProvider registered in the DI container. + * Default: no-op returning { mergedCount: 0 }. + * Consumers can override this activity at the worker level with custom findings integration. + */ +export async function mergeFindingsIntoQueue( + input: ActivityInput, + vulnType: VulnType, +): Promise<{ mergedCount: number }> { + const container = getContainer(input.workflowId); + if (!container?.findingsProvider) return { mergedCount: 0 }; + return container.findingsProvider.mergeFindingsIntoQueue(input.repoPath, vulnType, input); +} + +/** + * Persist pipeline state after an agent completes. + * + * Delegates to the CheckpointProvider registered in the DI container. + * Default: no-op. Consumers can override this activity at the worker level with custom persistence. + */ +export async function saveCheckpoint( + input: ActivityInput, + agentName: string, + phase: string, + state: PipelineState, +): Promise { + const container = getContainer(input.workflowId); + if (!container?.checkpointProvider) return; + return container.checkpointProvider.onAgentComplete(agentName, phase, state); +} diff --git a/apps/worker/src/temporal/pipeline.ts b/apps/worker/src/temporal/pipeline.ts new file mode 100644 index 0000000..7f74aa6 --- /dev/null +++ b/apps/worker/src/temporal/pipeline.ts @@ -0,0 +1,17 @@ +/** + * Pipeline entry point — re-exports the extracted pipeline function and shared types. + * + * Consumers import from this module to call the pipeline as a library function + * within their own workflow context. + */ + +export { pentestPipeline } from './workflows.js'; +export type { + AgentMetrics, + PipelineInput, + PipelineState, + PipelineSummary, + ResumeState, + VulnExploitPipelineResult, +} from './shared.js'; +export type { ActivityInput } from './activities.js'; diff --git a/apps/worker/src/temporal/shared.ts b/apps/worker/src/temporal/shared.ts index c7615f1..583fce3 100644 --- a/apps/worker/src/temporal/shared.ts +++ b/apps/worker/src/temporal/shared.ts @@ -2,7 +2,8 @@ import { defineQuery } from '@temporalio/workflow'; export type { AgentMetrics } from '../types/metrics.js'; -import type { PipelineConfig } from '../types/config.js'; +import type { DistributedConfig, PipelineConfig, ProviderConfig } from '../types/config.js'; +import type { ErrorCode } from '../types/errors.js'; import type { AgentMetrics } from '../types/metrics.js'; export interface PipelineInput { @@ -16,6 +17,18 @@ export interface PipelineInput { sessionId?: string; // Workspace directory name (distinct from workflowId for named workspaces) resumeFromWorkspace?: string; // Workspace name to resume from terminatedWorkflows?: string[]; // Workflows terminated during resume + + // Config fields — serializable, flow through to ActivityInput → getOrCreateContainer() + configYAML?: string; // Raw YAML string (parsed in activity, not workflow — workflow sandbox can't use Node.js) + configData?: DistributedConfig; // Pre-parsed config (bypasses file loading) + apiKey?: string; // API key override (avoids process.env mutation) + deliverablesSubdir?: string; // Override deliverables path (default: '.shannon/deliverables') + auditDir?: string; // Override audit log directory (default: './workspaces') + promptDir?: string; // Override prompt template directory + sastSarifPath?: string; // Path to SARIF file (gates SAST-enhanced mode) + checkpointsEnabled?: boolean; // Enable checkpoint activities (default: false) + skipGitCheck?: boolean; // Skip .git directory validation in preflight (e.g. when .git is removed after clone) + providerConfig?: ProviderConfig; // LLM provider configuration (Bedrock, Vertex, LiteLLM, etc.) } export interface ResumeState { @@ -34,12 +47,13 @@ export interface PipelineSummary { } export interface PipelineState { - status: 'running' | 'completed' | 'failed'; + status: 'running' | 'completed' | 'failed' | 'cancelled'; currentPhase: string | null; currentAgent: string | null; completedAgents: string[]; failedAgent: string | null; error: string | null; + errorCode?: ErrorCode; startTime: number; agentMetrics: Record; summary: PipelineSummary | null; diff --git a/apps/worker/src/temporal/summary-mapper.ts b/apps/worker/src/temporal/summary-mapper.ts index 7b44160..d8a92c8 100644 --- a/apps/worker/src/temporal/summary-mapper.ts +++ b/apps/worker/src/temporal/summary-mapper.ts @@ -19,7 +19,7 @@ import type { PipelineState } from './shared.js'; * safely imported into Temporal workflows. The caller must ensure * state.summary is set before calling (via computeSummary). */ -export function toWorkflowSummary(state: PipelineState, status: 'completed' | 'failed'): WorkflowSummary { +export function toWorkflowSummary(state: PipelineState, status: 'completed' | 'failed' | 'cancelled'): WorkflowSummary { // state.summary must be computed before calling this mapper const summary = state.summary; if (!summary) { diff --git a/apps/worker/src/temporal/worker.ts b/apps/worker/src/temporal/worker.ts index 70fc5bb..78c5633 100644 --- a/apps/worker/src/temporal/worker.ts +++ b/apps/worker/src/temporal/worker.ts @@ -35,6 +35,7 @@ import { bundleWorkflowCode, NativeConnection, Worker } from '@temporalio/worker import dotenv from 'dotenv'; import { sanitizeHostname } from '../audit/utils.js'; import { parseConfig } from '../config-parser.js'; +import { deliverablesDir } from '../paths.js'; import type { PipelineConfig } from '../types/config.js'; import { fileExists, readJson } from '../utils/file-io.js'; import * as activities from './activities.js'; @@ -360,13 +361,13 @@ async function waitForWorkflowResult( // === Deliverables Copy === function copyDeliverables(repoPath: string, outputPath: string): void { - const deliverablesDir = path.join(repoPath, '.shannon', 'deliverables'); - if (!fs.existsSync(deliverablesDir)) { + const outputDir = deliverablesDir(repoPath); + if (!fs.existsSync(outputDir)) { console.log('No deliverables directory found, skipping copy'); return; } - const files = fs.readdirSync(deliverablesDir); + const files = fs.readdirSync(outputDir); if (files.length === 0) { console.log('No deliverables to copy'); return; @@ -376,7 +377,7 @@ function copyDeliverables(repoPath: string, outputPath: string): void { for (const file of files) { if (file === '.git') continue; - const src = path.join(deliverablesDir, file); + const src = path.join(outputDir, file); const dest = path.join(outputPath, file); fs.cpSync(src, dest, { recursive: true }); } diff --git a/apps/worker/src/temporal/workflow-errors.ts b/apps/worker/src/temporal/workflow-errors.ts index ceb1670..35b89a0 100644 --- a/apps/worker/src/temporal/workflow-errors.ts +++ b/apps/worker/src/temporal/workflow-errors.ts @@ -9,6 +9,39 @@ * Pure functions with no side effects — safe for Temporal workflow sandbox. */ +import { ErrorCode } from '../types/errors.js'; + +/** + * Maps an ApplicationFailure type string to a structured ErrorCode. + * + * Activities classify errors via classifyErrorForTemporal() and throw + * ApplicationFailure with a type string. This function maps those strings + * to stable ErrorCode values so consumers can switch on codes instead of + * string-matching error messages. + */ +const ERROR_TYPE_TO_CODE: Record = { + AuthenticationError: ErrorCode.AUTH_FAILED, + BillingError: ErrorCode.BILLING_ERROR, + RateLimitError: ErrorCode.API_RATE_LIMITED, + ConfigurationError: ErrorCode.CONFIG_VALIDATION_FAILED, + OutputValidationError: ErrorCode.OUTPUT_VALIDATION_FAILED, + AgentExecutionError: ErrorCode.AGENT_EXECUTION_FAILED, + GitError: ErrorCode.GIT_CHECKPOINT_FAILED, + InvalidTargetError: ErrorCode.TARGET_UNREACHABLE, +}; + +export function classifyErrorCode(error: unknown): ErrorCode | undefined { + let current: unknown = error; + while (current instanceof Error) { + if ('type' in current && typeof (current as { type: unknown }).type === 'string') { + const code = ERROR_TYPE_TO_CODE[(current as { type: string }).type]; + if (code) return code; + } + current = (current as { cause?: unknown }).cause; + } + return undefined; +} + /** 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.', diff --git a/apps/worker/src/temporal/workflows.ts b/apps/worker/src/temporal/workflows.ts index 7e1c346..e4f4293 100644 --- a/apps/worker/src/temporal/workflows.ts +++ b/apps/worker/src/temporal/workflows.ts @@ -23,7 +23,14 @@ * - Graceful failure handling: pipelines continue if one fails */ -import { log, proxyActivities, setHandler, workflowInfo } from '@temporalio/workflow'; +import { + ApplicationFailure, + isCancellation, + log, + proxyActivities, + setHandler, + workflowInfo, +} from '@temporalio/workflow'; import type { AgentName, VulnType } from '../types/agents.js'; import { ALL_AGENTS } from '../types/agents.js'; import type * as activities from './activities.js'; @@ -39,7 +46,7 @@ import { type VulnExploitPipelineResult, } from './shared.js'; import { toWorkflowSummary } from './summary-mapper.js'; -import { formatWorkflowError } from './workflow-errors.js'; +import { classifyErrorCode, formatWorkflowError } from './workflow-errors.js'; // Retry configuration for production (long intervals for billing recovery) const PRODUCTION_RETRY = { @@ -127,7 +134,28 @@ function computeSummary(state: PipelineState): PipelineSummary { }; } -export async function pentestPipelineWorkflow(input: PipelineInput): Promise { +/** + * Core pipeline orchestration. Coordinates the pentest pipeline stages. + * + * IMPORTANT: This function uses Temporal workflow APIs internally (proxyActivities, + * queries). It can ONLY be called from within a Temporal workflow execution. + * Do not call from standalone scripts or activity code. + */ +export async function pentestPipeline(input: PipelineInput): Promise { + // Validate repoPath: reject traversal attempts and require absolute path + if (!input.repoPath || input.repoPath.includes('..')) { + throw ApplicationFailure.nonRetryable( + `Invalid repoPath: path traversal not allowed (received: ${input.repoPath ?? ''})`, + 'ConfigurationError', + ); + } + if (!input.repoPath.startsWith('/')) { + throw ApplicationFailure.nonRetryable( + `Invalid repoPath: absolute path required (received: ${input.repoPath})`, + 'ConfigurationError', + ); + } + const { workflowId } = workflowInfo(); // Select activity proxy based on mode: testing (fast), subscription (extended), or default @@ -176,20 +204,29 @@ export async function pentestPipelineWorkflow(input: PipelineInput): Promise !resumeState?.completedAgents.includes(agentName), ) as AgentName[]; - await a.restoreGitCheckpoint(input.repoPath, resumeState.checkpointHash, incompleteAgents); + await a.restoreGitCheckpoint(input.repoPath, resumeState.checkpointHash, incompleteAgents, input.deliverablesSubdir); // 3. Short-circuit if all agents already completed if (resumeState.completedAgents.length === ALL_AGENTS.length) { @@ -228,6 +265,9 @@ export async function pentestPipelineWorkflow(input: PipelineInput): Promise { + return pentestPipeline(input); +} diff --git a/apps/worker/src/types/config.ts b/apps/worker/src/types/config.ts index b117387..6888b65 100644 --- a/apps/worker/src/types/config.ts +++ b/apps/worker/src/types/config.ts @@ -62,3 +62,44 @@ export interface DistributedConfig { authentication: Authentication | null; description: string; } + +/** + * LLM provider configuration for multi-provider support. + * + * Maps to SDK environment variables at execution time. When providerType + * is omitted or 'anthropic_api', falls back to apiKey + ANTHROPIC_API_KEY. + */ +export interface ProviderConfig { + readonly providerType?: string; + readonly apiKey?: string; + readonly awsRegion?: string; + readonly awsAccessKeyId?: string; + readonly awsSecretAccessKey?: string; + readonly gcpRegion?: string; + readonly gcpProjectId?: string; + readonly gcpCredentialsPath?: string; + readonly baseUrl?: string; + readonly authToken?: string; + readonly routerDefault?: string; + readonly modelOverrides?: Record; + readonly supportsStructuredOutput?: boolean; +} + +/** + * Runtime configuration for the DI container. + * + * Abstracts path conventions and credential threading so consumers + * can override OSS defaults without modifying source files. + */ +export interface ContainerConfig { + /** Subdirectory for deliverables relative to repoPath. Default: '.shannon/deliverables' */ + readonly deliverablesSubdir: string; + /** Directory for audit logs. Default: './workspaces' */ + readonly auditDir: string; + /** API key override — when set, executor reads from config instead of process.env */ + readonly apiKey?: string; + /** Prompt directory override — when set, prompt manager loads from this path */ + readonly promptDir?: string; + /** LLM provider configuration — when set, executor maps to SDK env vars directly */ + readonly providerConfig?: ProviderConfig; +} diff --git a/entrypoint.sh b/entrypoint.sh index 80e0e2c..3173743 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,11 +6,11 @@ TARGET_GID="${SHANNON_HOST_GID:-}" CURRENT_UID=$(id -u pentest 2>/dev/null || echo "") if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then - deluser pentest 2>/dev/null || true - delgroup pentest 2>/dev/null || true + userdel pentest 2>/dev/null || true + groupdel pentest 2>/dev/null || true - addgroup -g "$TARGET_GID" pentest - adduser -u "$TARGET_UID" -G pentest -s /bin/bash -D pentest + groupadd -g "$TARGET_GID" pentest + useradd -u "$TARGET_UID" -g pentest -s /bin/bash -M pentest chown -R pentest:pentest /app/sessions /app/workspaces /tmp/.claude fi