mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-04-29 15:47:48 +02:00
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:
@@ -83,6 +83,7 @@ RUN apk update && apk add --no-cache \
|
||||
bash \
|
||||
curl \
|
||||
ca-certificates \
|
||||
shadow \
|
||||
# Network libraries (runtime)
|
||||
libpcap \
|
||||
# Security tools
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface AgentMetricsSummary {
|
||||
}
|
||||
|
||||
export interface WorkflowSummary {
|
||||
status: 'completed' | 'failed';
|
||||
status: 'completed' | 'failed' | 'cancelled';
|
||||
totalDurationMs: number;
|
||||
totalCostUsd: number;
|
||||
completedAgents: string[];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user