/** * SDK Options Factory - Centralized configuration for Claude Agent SDK * * Provides presets for common use cases: * - Spec generation: Long-running analysis with read-only tools * - Feature generation: Quick JSON generation from specs * - Feature building: Autonomous feature implementation with full tool access * - Suggestions: Analysis with read-only tools * - Chat: Full tool access for interactive coding * * Uses model-resolver for consistent model handling across the application. * * SECURITY: All factory functions validate the working directory (cwd) against * ALLOWED_ROOT_DIRECTORY before returning options. This provides a centralized * security check that applies to ALL AI model invocations, regardless of provider. */ import type { Options } from '@anthropic-ai/claude-agent-sdk'; import os from 'os'; import path from 'path'; import { resolveModelString } from '@automaker/model-resolver'; import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types'; import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; /** * Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY. * This is the centralized security check for ALL AI model invocations. * * @param cwd - The working directory to validate * @throws PathNotAllowedError if the directory is not within ALLOWED_ROOT_DIRECTORY * * This function is called by all create*Options() factory functions to ensure * that AI models can only operate within allowed directories. This applies to: * - All current models (Claude, future models) * - All invocation types (chat, auto-mode, spec generation, etc.) */ export function validateWorkingDirectory(cwd: string): void { const resolvedCwd = path.resolve(cwd); if (!isPathAllowed(resolvedCwd)) { const allowedRoot = getAllowedRootDirectory(); throw new PathNotAllowedError( `Working directory "${cwd}" (resolved: ${resolvedCwd}) is not allowed. ` + (allowedRoot ? `Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}` : 'ALLOWED_ROOT_DIRECTORY is configured but path is not within allowed directories.') ); } } /** * Known cloud storage path patterns where sandbox mode is incompatible. * * The Claude CLI sandbox feature uses filesystem isolation that conflicts with * cloud storage providers' virtual filesystem implementations. This causes the * Claude process to exit with code 1 when sandbox is enabled for these paths. * * Affected providers (macOS paths): * - Dropbox: ~/Library/CloudStorage/Dropbox-* * - Google Drive: ~/Library/CloudStorage/GoogleDrive-* * - OneDrive: ~/Library/CloudStorage/OneDrive-* * - iCloud Drive: ~/Library/Mobile Documents/ * - Box: ~/Library/CloudStorage/Box-* * * @see https://github.com/anthropics/claude-code/issues/XXX (TODO: file upstream issue) */ /** * macOS-specific cloud storage patterns that appear under ~/Library/ * These are specific enough to use with includes() safely. */ const MACOS_CLOUD_STORAGE_PATTERNS = [ '/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS '/Library/Mobile Documents/', // iCloud Drive on macOS ] as const; /** * Generic cloud storage folder names that need to be anchored to the home directory * to avoid false positives (e.g., /home/user/my-project-about-dropbox/). */ const HOME_ANCHORED_CLOUD_FOLDERS = [ 'Google Drive', // Google Drive on some systems 'Dropbox', // Dropbox on Linux/alternative installs 'OneDrive', // OneDrive on Linux/alternative installs ] as const; /** * Check if a path is within a cloud storage location. * * Cloud storage providers use virtual filesystem implementations that are * incompatible with the Claude CLI sandbox feature, causing process crashes. * * Uses two detection strategies: * 1. macOS-specific patterns (under ~/Library/) - checked via includes() * 2. Generic folder names - anchored to home directory to avoid false positives * * @param cwd - The working directory path to check * @returns true if the path is in a cloud storage location */ export function isCloudStoragePath(cwd: string): boolean { const resolvedPath = path.resolve(cwd); // Check macOS-specific patterns (these are specific enough to use includes) if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => resolvedPath.includes(pattern))) { return true; } // Check home-anchored patterns to avoid false positives // e.g., /home/user/my-project-about-dropbox/ should NOT match const home = os.homedir(); for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) { const cloudPath = path.join(home, folder); // Check if resolved path starts with the cloud storage path followed by a separator // This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool if (resolvedPath === cloudPath || resolvedPath.startsWith(cloudPath + path.sep)) { return true; } } return false; } /** * Result of sandbox compatibility check */ export interface SandboxCheckResult { /** Whether sandbox should be enabled */ enabled: boolean; /** If disabled, the reason why */ disabledReason?: 'cloud_storage' | 'user_setting'; /** Human-readable message for logging/UI */ message?: string; } /** * Determine if sandbox mode should be enabled for a given configuration. * * Sandbox mode is automatically disabled for cloud storage paths because the * Claude CLI sandbox feature is incompatible with virtual filesystem * implementations used by cloud storage providers (Dropbox, Google Drive, etc.). * * @param cwd - The working directory * @param enableSandboxMode - User's sandbox mode setting * @returns SandboxCheckResult with enabled status and reason if disabled */ export function checkSandboxCompatibility( cwd: string, enableSandboxMode?: boolean ): SandboxCheckResult { // User has explicitly disabled sandbox mode if (enableSandboxMode === false) { return { enabled: false, disabledReason: 'user_setting', }; } // Check for cloud storage incompatibility (applies when enabled or undefined) if (isCloudStoragePath(cwd)) { return { enabled: false, disabledReason: 'cloud_storage', message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`, }; } // Sandbox is compatible and enabled (true or undefined defaults to enabled) return { enabled: true, }; } /** * Tool presets for different use cases */ export const TOOL_PRESETS = { /** Read-only tools for analysis */ readOnly: ['Read', 'Glob', 'Grep'] as const, /** Tools for spec generation that needs to read the codebase */ specGeneration: ['Read', 'Glob', 'Grep'] as const, /** Full tool access for feature implementation */ fullAccess: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const, /** Tools for chat/interactive mode */ chat: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'] as const, } as const; /** * Max turns presets for different use cases */ export const MAX_TURNS = { /** Quick operations that shouldn't need many iterations */ quick: 50, /** Standard operations */ standard: 100, /** Long-running operations like full spec generation */ extended: 250, /** Very long operations that may require extensive exploration */ maximum: 1000, } as const; /** * Model presets for different use cases * * These can be overridden via environment variables: * - AUTOMAKER_MODEL_SPEC: Model for spec generation * - AUTOMAKER_MODEL_FEATURES: Model for feature generation * - AUTOMAKER_MODEL_SUGGESTIONS: Model for suggestions * - AUTOMAKER_MODEL_CHAT: Model for chat * - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations */ export function getModelForUseCase( useCase: 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default', explicitModel?: string ): string { // Explicit model takes precedence if (explicitModel) { return resolveModelString(explicitModel); } // Check environment variable override for this use case const envVarMap: Record = { spec: process.env.AUTOMAKER_MODEL_SPEC, features: process.env.AUTOMAKER_MODEL_FEATURES, suggestions: process.env.AUTOMAKER_MODEL_SUGGESTIONS, chat: process.env.AUTOMAKER_MODEL_CHAT, auto: process.env.AUTOMAKER_MODEL_AUTO, default: process.env.AUTOMAKER_MODEL_DEFAULT, }; const envModel = envVarMap[useCase] || envVarMap.default; if (envModel) { return resolveModelString(envModel); } const defaultModels: Record = { spec: CLAUDE_MODEL_MAP['haiku'], // used to generate app specs features: CLAUDE_MODEL_MAP['haiku'], // used to generate features from app specs suggestions: CLAUDE_MODEL_MAP['haiku'], // used for suggestions chat: CLAUDE_MODEL_MAP['haiku'], // used for chat auto: CLAUDE_MODEL_MAP['opus'], // used to implement kanban cards default: CLAUDE_MODEL_MAP['opus'], }; return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude); } /** * Base options that apply to all SDK calls */ function getBaseOptions(): Partial { return { permissionMode: 'acceptEdits', }; } /** * MCP permission options result */ interface McpPermissionOptions { /** Whether tools should be restricted to a preset */ shouldRestrictTools: boolean; /** Options to spread when MCP bypass is enabled */ bypassOptions: Partial; /** Options to spread for MCP servers */ mcpServerOptions: Partial; } /** * Build MCP-related options based on configuration. * Centralizes the logic for determining permission modes and tool restrictions * when MCP servers are configured. * * @param config - The SDK options config * @returns Object with MCP permission settings to spread into final options */ function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions { const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0; // Default to true for autonomous workflow. Security is enforced when adding servers // via the security warning dialog that explains the risks. const mcpAutoApprove = config.mcpAutoApproveTools ?? true; const mcpUnrestricted = config.mcpUnrestrictedTools ?? true; // Determine if we should bypass permissions based on settings const shouldBypassPermissions = hasMcpServers && mcpAutoApprove; // Determine if we should restrict tools (only when no MCP or unrestricted is disabled) const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted; return { shouldRestrictTools, // Only include bypass options when MCP is configured and auto-approve is enabled bypassOptions: shouldBypassPermissions ? { permissionMode: 'bypassPermissions' as const, // Required flag when using bypassPermissions mode allowDangerouslySkipPermissions: true, } : {}, // Include MCP servers if configured mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {}, }; } /** * Build system prompt configuration based on autoLoadClaudeMd setting. * When autoLoadClaudeMd is true: * - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading * - If there's a custom systemPrompt, appends it to the preset * - Sets settingSources to ['project'] for SDK to load CLAUDE.md files * * @param config - The SDK options config * @returns Object with systemPrompt and settingSources for SDK options */ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): { systemPrompt?: string | SystemPromptConfig; settingSources?: Array<'user' | 'project' | 'local'>; } { if (!config.autoLoadClaudeMd) { // Standard mode - just pass through the system prompt as-is return config.systemPrompt ? { systemPrompt: config.systemPrompt } : {}; } // Auto-load CLAUDE.md mode - use preset with settingSources const result: { systemPrompt: SystemPromptConfig; settingSources: Array<'user' | 'project' | 'local'>; } = { systemPrompt: { type: 'preset', preset: 'claude_code', }, // Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings settingSources: ['user', 'project'], }; // If there's a custom system prompt, append it to the preset if (config.systemPrompt) { result.systemPrompt.append = config.systemPrompt; } return result; } /** * System prompt configuration for SDK options * When using preset mode with claude_code, CLAUDE.md files are automatically loaded */ export interface SystemPromptConfig { /** Use preset mode with claude_code to enable CLAUDE.md auto-loading */ type: 'preset'; /** The preset to use - 'claude_code' enables CLAUDE.md loading */ preset: 'claude_code'; /** Optional additional prompt to append to the preset */ append?: string; } /** * Options configuration for creating SDK options */ export interface CreateSdkOptionsConfig { /** Working directory for the agent */ cwd: string; /** Optional explicit model override */ model?: string; /** Optional session model (used as fallback if explicit model not provided) */ sessionModel?: string; /** Optional system prompt */ systemPrompt?: string; /** Optional abort controller for cancellation */ abortController?: AbortController; /** Optional output format for structured outputs */ outputFormat?: { type: 'json_schema'; schema: Record; }; /** Enable auto-loading of CLAUDE.md files via SDK's settingSources */ autoLoadClaudeMd?: boolean; /** Enable sandbox mode for bash command isolation */ enableSandboxMode?: boolean; /** MCP servers to make available to the agent */ mcpServers?: Record; /** Auto-approve MCP tool calls without permission prompts */ mcpAutoApproveTools?: boolean; /** Allow unrestricted tools when MCP servers are enabled */ mcpUnrestrictedTools?: boolean; } // Re-export MCP types from @automaker/types for convenience export type { McpServerConfig, McpStdioServerConfig, McpSSEServerConfig, McpHttpServerConfig, } from '@automaker/types'; /** * Create SDK options for spec generation * * Configuration: * - Uses read-only tools for codebase analysis * - Extended turns for thorough exploration * - Opus model by default (can be overridden) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); return { ...getBaseOptions(), // Override permissionMode - spec generation only needs read-only tools // Using "acceptEdits" can cause Claude to write files to unexpected locations // See: https://github.com/AutoMaker-Org/automaker/issues/149 permissionMode: 'default', model: getModelForUseCase('spec', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.specGeneration], ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), ...(config.outputFormat && { outputFormat: config.outputFormat }), }; } /** * Create SDK options for feature generation from specs * * Configuration: * - Uses read-only tools (just needs to read the spec) * - Quick turns since it's mostly JSON generation * - Sonnet model by default for speed * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); return { ...getBaseOptions(), // Override permissionMode - feature generation only needs read-only tools permissionMode: 'default', model: getModelForUseCase('features', config.model), maxTurns: MAX_TURNS.quick, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.readOnly], ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), }; } /** * Create SDK options for generating suggestions * * Configuration: * - Uses read-only tools for analysis * - Standard turns to allow thorough codebase exploration and structured output generation * - Opus model by default for thorough analysis * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); return { ...getBaseOptions(), model: getModelForUseCase('suggestions', config.model), maxTurns: MAX_TURNS.extended, cwd: config.cwd, allowedTools: [...TOOL_PRESETS.readOnly], ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), ...(config.outputFormat && { outputFormat: config.outputFormat }), }; } /** * Create SDK options for chat/interactive mode * * Configuration: * - Full tool access for code modification * - Standard turns for interactive sessions * - Model priority: explicit model > session model > chat default * - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createChatOptions(config: CreateSdkOptionsConfig): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); // Model priority: explicit model > session model > chat default const effectiveModel = config.model || config.sessionModel; // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); // Build MCP-related options const mcpOptions = buildMcpOptions(config); // Check sandbox compatibility (auto-disables for cloud storage paths) const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); return { ...getBaseOptions(), model: getModelForUseCase('chat', effectiveModel), maxTurns: MAX_TURNS.standard, cwd: config.cwd, // Only restrict tools if no MCP servers configured or unrestricted is disabled ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }), // Apply MCP bypass options if configured ...mcpOptions.bypassOptions, ...(sandboxCheck.enabled && { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, }, }), ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), ...mcpOptions.mcpServerOptions, }; } /** * Create SDK options for autonomous feature building/implementation * * Configuration: * - Full tool access for code modification and implementation * - Extended turns for thorough feature implementation * - Uses default model (can be overridden) * - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage) * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); // Build MCP-related options const mcpOptions = buildMcpOptions(config); // Check sandbox compatibility (auto-disables for cloud storage paths) const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode); return { ...getBaseOptions(), model: getModelForUseCase('auto', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, // Only restrict tools if no MCP servers configured or unrestricted is disabled ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }), // Apply MCP bypass options if configured ...mcpOptions.bypassOptions, ...(sandboxCheck.enabled && { sandbox: { enabled: true, autoAllowBashIfSandboxed: true, }, }), ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), ...mcpOptions.mcpServerOptions, }; } /** * Create custom SDK options with explicit configuration * * Use this when the preset options don't fit your use case. * When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading */ export function createCustomOptions( config: CreateSdkOptionsConfig & { maxTurns?: number; allowedTools?: readonly string[]; sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; } ): Options { // Validate working directory before creating options validateWorkingDirectory(config.cwd); // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); // Build MCP-related options const mcpOptions = buildMcpOptions(config); // For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings const effectiveAllowedTools = config.allowedTools ? [...config.allowedTools] : mcpOptions.shouldRestrictTools ? [...TOOL_PRESETS.readOnly] : undefined; return { ...getBaseOptions(), model: getModelForUseCase('default', config.model), maxTurns: config.maxTurns ?? MAX_TURNS.maximum, cwd: config.cwd, ...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }), ...(config.sandbox && { sandbox: config.sandbox }), // Apply MCP bypass options if configured ...mcpOptions.bypassOptions, ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), ...mcpOptions.mcpServerOptions, }; }