feat: extract pipeline core for library consumption (#282)

* feat: extract pipeline core for library consumption

* fix: chmod workspace directory for container write access

* fix: resolve playwright output dir relative to deliverables parent

* feat: add multi-provider LLM support via ProviderConfig

* fix: resolve model overrides via options.model, remove unused model env passthrough

* fix: use ANTHROPIC_AUTH_TOKEN for custom base URL and router auth

* fix: skip env-based credential validation when providerConfig is present

* fix: support large UID/GID values for AD/LDAP users in container
This commit is contained in:
ezl-keygraph
2026-04-10 04:53:36 +05:30
committed by GitHub
parent f6fd1edad6
commit 1f6dfd7e17
32 changed files with 616 additions and 106 deletions
+1
View File
@@ -83,6 +83,7 @@ RUN apk update && apk add --no-cache \
bash \
curl \
ca-certificates \
shadow \
# Network libraries (runtime)
libpcap \
# Security tools
+9 -3
View File
@@ -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<void> {
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<void> {
}
// 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();
-4
View File
@@ -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' };
}
+10
View File
@@ -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",
+55 -17
View File
@@ -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<ClaudePromptResult> {
// 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<string, string> = {
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,
+1 -1
View File
@@ -202,7 +202,7 @@ export class AuditSession {
/**
* Update session status
*/
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed'): Promise<void> {
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed' | 'cancelled'): Promise<void> {
await this.ensureInitialized();
const unlock = await sessionMutex.lock(this.sessionId);
+3 -3
View File
@@ -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<void> {
async updateSessionStatus(status: 'in-progress' | 'completed' | 'failed' | 'cancelled'): Promise<void> {
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();
}
+1 -1
View File
@@ -30,7 +30,7 @@ export interface AgentMetricsSummary {
}
export interface WorkflowSummary {
status: 'completed' | 'failed';
status: 'completed' | 'failed' | 'cancelled';
totalDurationMs: number;
totalCostUsd: number;
completedAgents: string[];
+48
View File
@@ -258,6 +258,54 @@ export const parseConfig = async (configPath: string): Promise<Config> => {
}
};
/**
* 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(
@@ -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<void>;
}
/** Default no-op implementation — no external checkpointing. */
export class NoOpCheckpointProvider implements CheckpointProvider {
async onAgentComplete(): Promise<void> {
// No-op
}
}
@@ -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 };
}
}
+11
View File
@@ -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';
+15
View File
@@ -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.
+2 -1
View File
@@ -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 {
+15 -8
View File
@@ -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<Result<AgentEndResult, PentestError>> {
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,
+22 -2
View File
@@ -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<Result<DistributedConfig | null, PentestError>> {
async loadOptional(
configPath: string | undefined,
configData?: DistributedConfig,
configYAML?: string,
): Promise<Result<DistributedConfig | null, PentestError>> {
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);
}
+29 -2
View File
@@ -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<string, Container>();
/** 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<string, Container>();
*
* @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);
}
+1 -1
View File
@@ -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';
+40 -21
View File
@@ -39,7 +39,7 @@ function isLoopbackAddress(address: string): boolean {
// === Repository Validation ===
async function validateRepo(repoPath: string, logger: ActivityLogger): Promise<Result<void, PentestError>> {
async function validateRepo(repoPath: string, logger: ActivityLogger, skipGitCheck?: boolean): Promise<Result<void, PentestError>> {
logger.info('Checking repository path...', { repoPath });
// 1. Check repo directory exists
@@ -68,10 +68,22 @@ async function validateRepo(repoPath: string, logger: ActivityLogger): Promise<R
);
}
// 2. Check .git directory exists
try {
const gitStats = await fs.stat(`${repoPath}/.git`);
if (!gitStats.isDirectory()) {
// 2. Check .git directory exists (skipped when consumer removes .git after clone)
if (!skipGitCheck) {
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}`,
@@ -82,16 +94,8 @@ async function validateRepo(repoPath: string, logger: ActivityLogger): Promise<R
),
);
}
} catch {
return err(
new PentestError(
`Not a git repository (no .git directory): ${repoPath}`,
'config',
false,
{ repoPath },
ErrorCode.REPO_NOT_FOUND,
),
);
} else {
logger.info('Skipping .git check (skipGitCheck enabled)');
}
logger.info('Repository path OK');
@@ -180,9 +184,21 @@ function classifySdkError(sdkError: SDKAssistantMessageError, authType: string):
}
/** Validate credentials via a minimal Claude Agent SDK query. */
async function validateCredentials(logger: ActivityLogger): Promise<Result<void, PentestError>> {
async function validateCredentials(logger: ActivityLogger, apiKey?: string, providerConfig?: import('../types/config.js').ProviderConfig): Promise<Result<void, PentestError>> {
// 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<Result<void,
}
// 4. Check that at least one credential is present
if (!process.env.ANTHROPIC_API_KEY && !process.env.CLAUDE_CODE_OAUTH_TOKEN) {
if (!process.env.ANTHROPIC_API_KEY && !process.env.CLAUDE_CODE_OAUTH_TOKEN && !process.env.ANTHROPIC_AUTH_TOKEN) {
return err(
new PentestError(
'No API credentials found. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env (or use CLAUDE_CODE_USE_BEDROCK=1 for AWS Bedrock, or CLAUDE_CODE_USE_VERTEX=1 for Google Vertex AI)',
@@ -457,9 +473,12 @@ export async function runPreflightChecks(
repoPath: string,
configPath: string | undefined,
logger: ActivityLogger,
skipGitCheck?: boolean,
apiKey?: string,
providerConfig?: import('../types/config.js').ProviderConfig,
): Promise<Result<void, PentestError>> {
// 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;
}
+13 -6
View File
@@ -23,10 +23,14 @@ interface IncludeReplacement {
}
// Pure function: Build complete login instructions from config
async function buildLoginInstructions(authentication: Authentication, logger: ActivityLogger): Promise<string> {
async function buildLoginInstructions(
authentication: Authentication,
logger: ActivityLogger,
promptsBaseDir: string = PROMPTS_DIR,
): Promise<string> {
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<string> {
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<string> {
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;
+3 -2
View File
@@ -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,
});
};
+6 -5
View File
@@ -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');
+5 -4
View File
@@ -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<boolean> => {
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<string, PlaywrightSession> = Obj
export const AGENT_VALIDATORS: Record<AgentName, AgentValidator> = Object.freeze({
// Pre-reconnaissance agent - validates the code analysis deliverable created by the agent
'pre-recon': async (sourceDir: string): Promise<boolean> => {
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<boolean> => {
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<AgentName, AgentValidator> = Object.freeze
// Executive report agent
report: async (sourceDir: string, logger: ActivityLogger): Promise<boolean> => {
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);
+82 -9
View File
@@ -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<void
const logger = createActivityLogger();
logger.info('Running preflight validation...', { attempt: attemptNumber });
const result = await runPreflightChecks(input.webUrl, input.repoPath, input.configPath, logger);
const result = await runPreflightChecks(input.webUrl, input.repoPath, input.configPath, logger, input.skipGitCheck, input.apiKey, input.providerConfig);
if (isErr(result)) {
const classified = classifyErrorForTemporal(result.error);
@@ -318,7 +354,7 @@ export async function runPreflightValidation(input: ActivityInput): Promise<void
* Idempotent — skips if .git already exists (resume case).
*/
export async function initDeliverableGit(input: ActivityInput): Promise<void> {
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<ResumeState> {
// 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<void> {
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<void> {
const container = getContainer(input.workflowId);
if (!container?.checkpointProvider) return;
return container.checkpointProvider.onAgentComplete(agentName, phase, state);
}
+17
View File
@@ -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';
+16 -2
View File
@@ -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<string, AgentMetrics>;
summary: PipelineSummary | null;
+1 -1
View File
@@ -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) {
+5 -4
View File
@@ -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 });
}
@@ -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<string, ErrorCode> = {
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<string, string> = {
AuthenticationError: 'Verify ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in .env is valid and not expired.',
+75 -5
View File
@@ -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<PipelineState> {
/**
* 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<PipelineState> {
// 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 ?? '<empty>'})`,
'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<Pip
...(input.pipelineTestingMode !== undefined && {
pipelineTestingMode: input.pipelineTestingMode,
}),
// Config fields — flow through to getOrCreateContainer()
...(input.configYAML !== undefined && { configYAML: input.configYAML }),
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
...(input.deliverablesSubdir !== undefined && { deliverablesSubdir: input.deliverablesSubdir }),
...(input.auditDir !== undefined && { auditDir: input.auditDir }),
...(input.promptDir !== undefined && { promptDir: input.promptDir }),
...(input.sastSarifPath !== undefined && { sastSarifPath: input.sastSarifPath }),
...(input.skipGitCheck !== undefined && { skipGitCheck: input.skipGitCheck }),
...(input.providerConfig !== undefined && { providerConfig: input.providerConfig }),
};
let resumeState: ResumeState | null = null;
if (input.resumeFromWorkspace) {
// 1. Load resume state (validates workspace, cross-checks deliverables)
resumeState = await a.loadResumeState(input.resumeFromWorkspace, input.webUrl, input.repoPath);
resumeState = await a.loadResumeState(input.resumeFromWorkspace, input.webUrl, input.repoPath, input.deliverablesSubdir);
// 2. Restore git workspace and clean up incomplete deliverables
const incompleteAgents = ALL_AGENTS.filter(
(agentName) => !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<Pip
await a.logPhaseTransition(activityInput, phaseName, 'start');
state.agentMetrics[agentName] = await runAgent(activityInput);
state.completedAgents.push(agentName);
if (input.checkpointsEnabled) {
await a.saveCheckpoint(activityInput, agentName, phaseName, state);
}
await a.logPhaseTransition(activityInput, phaseName, 'complete');
} else {
log.info(`Skipping ${agentName} (already complete)`);
@@ -392,10 +432,16 @@ export async function pentestPipelineWorkflow(input: PipelineInput): Promise<Pip
let vulnMetrics: AgentMetrics | null = null;
if (!shouldSkip(vulnAgentName)) {
vulnMetrics = await runVulnAgent();
if (input.checkpointsEnabled) {
await a.saveCheckpoint(activityInput, vulnAgentName, 'vulnerability-analysis', state);
}
} else {
log.info(`Skipping ${vulnAgentName} (already complete)`);
}
// 1.5. Merge external findings (SAST, SCA, etc.) into exploitation queue
await a.mergeFindingsIntoQueue(activityInput, vulnType);
// 2. Check exploitation queue for actionable findings
const decision = await a.checkExploitationQueue(activityInput, vulnType);
@@ -404,6 +450,9 @@ export async function pentestPipelineWorkflow(input: PipelineInput): Promise<Pip
if (decision.shouldExploit) {
if (!shouldSkip(exploitAgentName)) {
exploitMetrics = await runExploitAgent();
if (input.checkpointsEnabled) {
await a.saveCheckpoint(activityInput, exploitAgentName, 'exploitation', state);
}
} else {
log.info(`Skipping ${exploitAgentName} (already complete)`);
}
@@ -454,6 +503,9 @@ export async function pentestPipelineWorkflow(input: PipelineInput): Promise<Pip
// Then run the report agent to add executive summary and clean up
state.agentMetrics.report = await a.runReportAgent(activityInput);
state.completedAgents.push('report');
if (input.checkpointsEnabled) {
await a.saveCheckpoint(activityInput, 'report', 'reporting', state);
}
// Inject model metadata into the final report
await a.injectReportMetadataActivity(activityInput);
@@ -474,9 +526,22 @@ export async function pentestPipelineWorkflow(input: PipelineInput): Promise<Pip
return state;
} catch (error) {
// Cancellation: return structured state instead of throwing
if (isCancellation(error)) {
state.status = 'cancelled';
state.error = `Cancelled during phase: ${state.currentPhase ?? 'unknown'}`;
state.summary = computeSummary(state);
await a.logWorkflowComplete(activityInput, toWorkflowSummary(state, 'cancelled'));
return state;
}
state.status = 'failed';
state.failedAgent = state.currentAgent;
state.error = formatWorkflowError(error, state.currentPhase, state.currentAgent);
const errorCode = classifyErrorCode(error);
if (errorCode) {
state.errorCode = errorCode;
}
state.summary = computeSummary(state);
// Log workflow failure summary
@@ -485,3 +550,8 @@ export async function pentestPipelineWorkflow(input: PipelineInput): Promise<Pip
throw error;
}
}
/** OSS workflow entry point — thin shell around the extracted pipeline function. */
export async function pentestPipelineWorkflow(input: PipelineInput): Promise<PipelineState> {
return pentestPipeline(input);
}
+41
View File
@@ -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<string, string>;
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;
}
+4 -4
View File
@@ -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