diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 6c22b09eef4..7efbba9dfc0 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -32,3 +32,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # Admin API (Optional - for self-hosted GitOps) # ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import. # Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces + +# Execute Command (Optional - self-hosted only) +# EXECUTE_COMMAND_ENABLED=true # Enable shell command execution in workflows +# NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED=true # Show Execute Command block in the UI diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 441bf788d9a..5d0a773c30d 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -366,7 +366,7 @@ function resolveWorkflowVariables( const variableName = match[1].trim() const foundVariable = Object.entries(workflowVariables).find( - ([_, variable]) => normalizeName(variable.name || '') === variableName + ([_, variable]) => normalizeName(variable.name || '') === normalizeName(variableName) ) if (!foundVariable) { diff --git a/apps/sim/app/api/tools/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts new file mode 100644 index 00000000000..435461f1b4f --- /dev/null +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -0,0 +1,424 @@ +import { exec } from 'child_process' +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { isExecuteCommandEnabled } from '@/lib/core/config/feature-flags' +import { generateRequestId } from '@/lib/core/utils/request' +import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' +import { normalizeName, REFERENCE, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' +import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' +import { + createEnvVarPattern, + createWorkflowVariablePattern, +} from '@/executor/utils/reference-validation' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const MAX_DURATION = 210 + +const logger = createLogger('ExecuteCommandAPI') + +const MAX_BUFFER = 10 * 1024 * 1024 // 10MB + +const SAFE_ENV_KEYS = ['PATH', 'HOME', 'SHELL', 'USER', 'LOGNAME', 'LANG', 'TERM', 'TZ'] as const + +/** + * Returns a minimal base environment for child processes. + * Only includes POSIX essentials — never server secrets like DATABASE_URL, AUTH_SECRET, etc. + */ +function getSafeBaseEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { NODE_ENV: process.env.NODE_ENV } + for (const key of SAFE_ENV_KEYS) { + if (process.env[key]) { + env[key] = process.env[key] + } + } + return env +} + +const BLOCKED_ENV_KEYS = new Set([ + ...SAFE_ENV_KEYS, + 'NODE_ENV', + 'NODE_OPTIONS', + 'LD_PRELOAD', + 'LD_LIBRARY_PATH', + 'LD_AUDIT', + 'DYLD_INSERT_LIBRARIES', + 'DYLD_LIBRARY_PATH', + 'DYLD_FRAMEWORK_PATH', + 'BASH_ENV', + 'ENV', + 'GLIBC_TUNABLES', + 'NODE_PATH', + 'PYTHONPATH', + 'PERL5LIB', + 'PERL5OPT', + 'RUBYLIB', + 'JAVA_TOOL_OPTIONS', +]) + +/** + * Filters user-supplied env vars to prevent overriding safe base env + * and injecting process-influencing variables. + */ +function filterUserEnv(env?: Record): Record { + if (!env) return {} + const filtered: Record = {} + for (const [key, value] of Object.entries(env)) { + if (!BLOCKED_ENV_KEYS.has(key)) { + filtered[key] = value + } + } + return filtered +} + +interface Replacement { + index: number + length: number + value: string +} + +/** + * Collects workflow variable () replacements from the original command string + */ +function collectWorkflowVariableReplacements( + command: string, + workflowVariables: Record +): Replacement[] { + const regex = createWorkflowVariablePattern() + const replacements: Replacement[] = [] + let match: RegExpExecArray | null + + while ((match = regex.exec(command)) !== null) { + const variableName = match[1].trim() + const foundVariable = Object.entries(workflowVariables).find( + ([_, variable]) => normalizeName(variable.name || '') === normalizeName(variableName) + ) + + if (!foundVariable) { + const availableVars = Object.values(workflowVariables) + .map((v: any) => v.name) + .filter(Boolean) + throw new Error( + `Variable "${variableName}" doesn't exist.` + + (availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '') + ) + } + + const variable = foundVariable[1] + let value = variable.value + + if (typeof value === 'object' && value !== null) { + value = JSON.stringify(value) + } else { + value = String(value ?? '') + } + + replacements.push({ index: match.index, length: match[0].length, value }) + } + + return replacements +} + +/** + * Collects environment variable ({{ENV_VAR}}) replacements from the original command string + */ +function collectEnvVarReplacements( + command: string, + envVars: Record +): Replacement[] { + const regex = createEnvVarPattern() + const replacements: Replacement[] = [] + let match: RegExpExecArray | null + + while ((match = regex.exec(command)) !== null) { + const varName = match[1].trim() + if (!(varName in envVars)) { + continue + } + replacements.push({ index: match.index, length: match[0].length, value: envVars[varName] }) + } + + return replacements +} + +/** + * Collects block reference tag () replacements from the original command string + */ +function collectTagReplacements( + command: string, + blockData: Record, + blockNameMapping: Record, + blockOutputSchemas: Record +): Replacement[] { + const tagPattern = new RegExp( + `${REFERENCE.START}(\\S[^${REFERENCE.START}${REFERENCE.END}]*)${REFERENCE.END}`, + 'g' + ) + + const replacements: Replacement[] = [] + let match: RegExpExecArray | null + + while ((match = tagPattern.exec(command)) !== null) { + const tagName = match[1].trim() + const pathParts = tagName.split(REFERENCE.PATH_DELIMITER) + const blockName = pathParts[0] + const fieldPath = pathParts.slice(1) + + const result = resolveBlockReference(blockName, fieldPath, { + blockNameMapping, + blockData, + blockOutputSchemas, + }) + + if (!result) { + const isSpecialPrefix = SPECIAL_REFERENCE_PREFIXES.some((prefix) => blockName === prefix) + if (!isSpecialPrefix) { + throw new Error( + `Block reference "<${tagName}>" could not be resolved. Check that the block name and field path are correct.` + ) + } + continue + } + + let stringValue: string + if (result.value === undefined || result.value === null) { + stringValue = '' + } else if (typeof result.value === 'object') { + stringValue = JSON.stringify(result.value) + } else { + stringValue = String(result.value) + } + + replacements.push({ index: match.index, length: match[0].length, value: stringValue }) + } + + return replacements +} + +/** + * Resolves all variable references in a command string in a single pass. + * All three patterns are matched against the ORIGINAL command to prevent + * cascading resolution (e.g. a workflow variable value containing {{ENV_VAR}} + * would NOT be further resolved). + */ +function resolveCommandVariables( + command: string, + envVars: Record, + blockData: Record, + blockNameMapping: Record, + blockOutputSchemas: Record, + workflowVariables: Record +): string { + const allReplacements = [ + ...collectWorkflowVariableReplacements(command, workflowVariables), + ...collectEnvVarReplacements(command, envVars), + ...collectTagReplacements(command, blockData, blockNameMapping, blockOutputSchemas), + ] + + allReplacements.sort((a, b) => a.index - b.index) + + for (let i = 1; i < allReplacements.length; i++) { + const prev = allReplacements[i - 1] + const curr = allReplacements[i] + if (curr.index < prev.index + prev.length) { + throw new Error( + `Overlapping variable references detected at positions ${prev.index} and ${curr.index}` + ) + } + } + + let resolved = command + for (let i = allReplacements.length - 1; i >= 0; i--) { + const { index, length, value } = allReplacements[i] + resolved = resolved.slice(0, index) + value + resolved.slice(index + length) + } + + return resolved +} + +interface CommandResult { + stdout: string + stderr: string + exitCode: number + timedOut: boolean + maxBufferExceeded: boolean +} + +/** + * Execute a shell command and return stdout, stderr, exitCode. + * Distinguishes between a process that exited with non-zero (normal) and one that was killed (timeout). + */ +function executeCommand( + command: string, + options: { timeout: number; cwd?: string; env?: Record } +): Promise { + return new Promise((resolve) => { + const childProcess = exec( + command, + { + timeout: options.timeout, + cwd: options.cwd || undefined, + maxBuffer: MAX_BUFFER, + env: { ...getSafeBaseEnv(), ...filterUserEnv(options.env) }, + }, + (error, stdout, stderr) => { + if (error) { + const killed = error.killed ?? false + const isMaxBuffer = /maxBuffer/i.test(error.message ?? '') + const exitCode = typeof error.code === 'number' ? error.code : 1 + resolve({ + stdout: stdout.trimEnd(), + stderr: stderr.trimEnd(), + exitCode, + timedOut: killed && !isMaxBuffer, + maxBufferExceeded: isMaxBuffer, + }) + return + } + resolve({ + stdout: stdout.trimEnd(), + stderr: stderr.trimEnd(), + exitCode: 0, + timedOut: false, + maxBufferExceeded: false, + }) + } + ) + + childProcess.on('error', (err) => { + resolve({ + stdout: '', + stderr: err.message, + exitCode: 1, + timedOut: false, + maxBufferExceeded: false, + }) + }) + }) +} + +export async function POST(req: NextRequest) { + const requestId = generateRequestId() + + try { + if (!isExecuteCommandEnabled) { + logger.warn(`[${requestId}] Execute Command is disabled`) + return NextResponse.json( + { + success: false, + error: + 'Execute Command is not enabled. Set EXECUTE_COMMAND_ENABLED=true in your environment to use this feature. Only available for self-hosted deployments.', + }, + { status: 403 } + ) + } + + const auth = await checkInternalAuth(req) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized execute command attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() + + const { + command, + workingDirectory, + envVars = {}, + blockData = {}, + blockNameMapping = {}, + blockOutputSchemas = {}, + workflowVariables = {}, + workflowId, + } = body + + const MAX_ALLOWED_TIMEOUT_MS = (MAX_DURATION - 10) * 1000 + const parsedTimeout = Number(body.timeout) + const timeout = Math.min( + parsedTimeout > 0 ? parsedTimeout : DEFAULT_EXECUTION_TIMEOUT_MS, + MAX_ALLOWED_TIMEOUT_MS + ) + + if (!command || typeof command !== 'string') { + return NextResponse.json( + { success: false, error: 'Command is required and must be a string' }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Execute command request`, { + commandLength: command.length, + timeout, + workingDirectory: workingDirectory || '(default)', + workflowId, + }) + + const resolvedCommand = resolveCommandVariables( + command, + envVars, + blockData, + blockNameMapping, + blockOutputSchemas, + workflowVariables + ) + + const result = await executeCommand(resolvedCommand, { + timeout, + cwd: workingDirectory, + env: envVars, + }) + + logger.info(`[${requestId}] Command completed`, { + exitCode: result.exitCode, + timedOut: result.timedOut, + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + workflowId, + }) + + if (result.timedOut) { + return NextResponse.json({ + success: false, + output: { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }, + error: `Command timed out after ${timeout}ms`, + }) + } + + if (result.maxBufferExceeded) { + return NextResponse.json({ + success: false, + output: { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }, + error: `Command output exceeded maximum buffer size of ${MAX_BUFFER / 1024 / 1024}MB`, + }) + } + + return NextResponse.json({ + success: true, + output: { + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Execute command failed`, { error: message }) + return NextResponse.json( + { + success: false, + output: { stdout: '', stderr: message, exitCode: 1 }, + error: message, + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/blocks/blocks/execute-command.ts b/apps/sim/blocks/blocks/execute-command.ts new file mode 100644 index 00000000000..311eed0c76a --- /dev/null +++ b/apps/sim/blocks/blocks/execute-command.ts @@ -0,0 +1,88 @@ +import { TerminalIcon } from '@/components/icons' +import { isTruthy } from '@/lib/core/config/env' +import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' +import type { BlockConfig } from '@/blocks/types' +import type { ExecuteCommandOutput } from '@/tools/execute-command/types' + +export const ExecuteCommandBlock: BlockConfig = { + type: 'execute_command', + name: 'Execute Command', + description: 'Run shell commands', + hideFromToolbar: !isTruthy(process.env.NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED), + longDescription: + 'Execute shell commands on the host machine. Only available for self-hosted deployments with EXECUTE_COMMAND_ENABLED=true. Commands run in the default shell of the host OS.', + bestPractices: ` + - Commands execute in the default shell of the host machine (bash, zsh, cmd, PowerShell). + - Chain multiple commands with && to run them sequentially. + - Use syntax to reference outputs from other blocks. + - Use {{ENV_VAR}} syntax to reference environment variables. + - The working directory defaults to the server process directory if not specified. Variable references are not supported in the Working Directory field — use a literal path. + - A non-zero exit code is returned as data (exitCode > 0), not treated as a workflow error. Use a Condition block to branch on exitCode if needed. + - Variable values from other blocks are interpolated directly into the command string. Avoid passing untrusted user input as block references to prevent shell injection. + `, + docsLink: 'https://docs.sim.ai/blocks/execute-command', + category: 'blocks', + bgColor: '#1E1E1E', + icon: TerminalIcon, + subBlocks: [ + { + id: 'command', + title: 'Command', + type: 'long-input', + required: true, + placeholder: 'echo "Hello, World!"', + wandConfig: { + enabled: true, + prompt: `You are an expert shell scripting assistant. +Generate ONLY the raw shell command(s) based on the user's request. Never wrap in markdown formatting. +The command runs in the default shell of the host OS (bash, zsh, cmd, or PowerShell). + +- Reference outputs from previous blocks using angle bracket syntax: +- Reference environment variables using double curly brace syntax: {{ENV_VAR_NAME}} +- Chain multiple commands with && to run them sequentially. +- Use pipes (|) to chain command output. + +Current command context: {context} + +IMPORTANT FORMATTING RULES: +1. Output ONLY the shell command(s). No explanations, no markdown, no comments. +2. Use to reference block outputs. Do NOT wrap in quotes. +3. Use {{VAR_NAME}} to reference environment variables. Do NOT wrap in quotes. +4. Write portable commands when possible (prefer POSIX-compatible syntax). +5. For multi-step operations, chain with && or use subshells.`, + placeholder: 'Describe the command you want to run...', + }, + }, + { + id: 'workingDirectory', + title: 'Working Directory', + type: 'short-input', + required: false, + placeholder: '/path/to/directory', + }, + { + id: 'timeout', + title: 'Timeout (ms)', + type: 'short-input', + required: false, + placeholder: String(DEFAULT_EXECUTION_TIMEOUT_MS), + }, + ], + tools: { + access: ['execute_command_run'], + }, + inputs: { + command: { type: 'string', description: 'Shell command to execute' }, + workingDirectory: { type: 'string', description: 'Working directory for the command' }, + timeout: { type: 'number', description: 'Execution timeout in milliseconds' }, + }, + outputs: { + stdout: { type: 'string', description: 'Standard output from the command' }, + stderr: { type: 'string', description: 'Standard error output from the command' }, + exitCode: { type: 'number', description: 'Exit code of the command (0 = success)' }, + error: { + type: 'string', + description: 'Error message if the command timed out or exceeded buffer limits', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 7ff0b918dd1..8f4fd25ac94 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -39,6 +39,7 @@ import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs' import { EnrichBlock } from '@/blocks/blocks/enrich' import { EvaluatorBlock } from '@/blocks/blocks/evaluator' import { ExaBlock } from '@/blocks/blocks/exa' +import { ExecuteCommandBlock } from '@/blocks/blocks/execute-command' import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file' import { FirecrawlBlock } from '@/blocks/blocks/firecrawl' import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies' @@ -235,6 +236,7 @@ export const registry: Record = { elevenlabs: ElevenLabsBlock, enrich: EnrichBlock, evaluator: EvaluatorBlock, + execute_command: ExecuteCommandBlock, exa: ExaBlock, file: FileBlock, file_v2: FileV2Block, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 5525e048cfa..e98bec40b21 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -367,6 +367,34 @@ export function CodeIcon(props: SVGProps) { ) } +export function TerminalIcon(props: SVGProps) { + return ( + + + + + ) +} + export function ChartBarIcon(props: SVGProps) { return ( + ): Promise { + const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx) + + const result = await executeTool( + 'execute_command_run', + { + command: inputs.command, + timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS, + workingDirectory: inputs.workingDirectory, + envVars: ctx.environmentVariables || {}, + workflowVariables: ctx.workflowVariables || {}, + blockData, + blockNameMapping, + blockOutputSchemas, + _context: { + workflowId: ctx.workflowId, + workspaceId: ctx.workspaceId, + userId: ctx.userId, + isDeployedContext: ctx.isDeployedContext, + enforceCredentialAccess: ctx.enforceCredentialAccess, + }, + }, + false, + ctx + ) + + if (!result.success) { + return { + ...(result.output ?? { stdout: '', stderr: '', exitCode: 1 }), + error: result.error || 'Command execution failed', + } + } + + return { ...result.output, error: null } + } +} diff --git a/apps/sim/executor/handlers/index.ts b/apps/sim/executor/handlers/index.ts index fb27421b2d9..2d61f6d3803 100644 --- a/apps/sim/executor/handlers/index.ts +++ b/apps/sim/executor/handlers/index.ts @@ -2,6 +2,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' import { ApiBlockHandler } from '@/executor/handlers/api/api-handler' import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler' import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler' +import { ExecuteCommandBlockHandler } from '@/executor/handlers/execute-command/execute-command-handler' import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler' import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler' @@ -17,6 +18,7 @@ export { ApiBlockHandler, ConditionBlockHandler, EvaluatorBlockHandler, + ExecuteCommandBlockHandler, FunctionBlockHandler, GenericBlockHandler, ResponseBlockHandler, diff --git a/apps/sim/executor/handlers/registry.ts b/apps/sim/executor/handlers/registry.ts index 9e977668a27..21f034ebda9 100644 --- a/apps/sim/executor/handlers/registry.ts +++ b/apps/sim/executor/handlers/registry.ts @@ -9,6 +9,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' import { ApiBlockHandler } from '@/executor/handlers/api/api-handler' import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler' import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler' +import { ExecuteCommandBlockHandler } from '@/executor/handlers/execute-command/execute-command-handler' import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler' import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler' @@ -30,6 +31,7 @@ export function createBlockHandlers(): BlockHandler[] { return [ new TriggerBlockHandler(), new FunctionBlockHandler(), + new ExecuteCommandBlockHandler(), new ApiBlockHandler(), new ConditionBlockHandler(), new RouterBlockHandler(), diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 774f4108823..4f2b7a33999 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -288,6 +288,9 @@ export const env = createEnv({ E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation + // Execute Command - for self-hosted deployments + EXECUTE_COMMAND_ENABLED: z.string().optional(), // Enable shell command execution (self-hosted only) + // Credential Sets (Email Polling) - for self-hosted deployments CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements) @@ -366,6 +369,7 @@ export const env = createEnv({ NEXT_PUBLIC_SUPPORT_EMAIL: z.string().email().optional(), // Custom support email NEXT_PUBLIC_E2B_ENABLED: z.string().optional(), + NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED: z.string().optional(), NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: z.string().optional(), NEXT_PUBLIC_ENABLE_PLAYGROUND: z.string().optional(), // Enable component playground at /playground NEXT_PUBLIC_DOCUMENTATION_URL: z.string().url().optional(), // Custom documentation URL @@ -420,6 +424,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED, + NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED: process.env.NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED, NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED, NEXT_PUBLIC_ENABLE_PLAYGROUND: process.env.NEXT_PUBLIC_ENABLE_PLAYGROUND, NEXT_PUBLIC_POSTHOG_ENABLED: process.env.NEXT_PUBLIC_POSTHOG_ENABLED, diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 2ae7a87afb1..0e0e8945861 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -105,6 +105,31 @@ export const isOrganizationsEnabled = */ export const isE2bEnabled = isTruthy(env.E2B_ENABLED) +/** + * Is Execute Command enabled for running shell commands on the host machine. + * Only available for self-hosted deployments. Blocked on hosted environments. + */ +export const isExecuteCommandEnabled = isTruthy(env.EXECUTE_COMMAND_ENABLED) && !isHosted + +if (isTruthy(env.EXECUTE_COMMAND_ENABLED)) { + import('@sim/logger') + .then(({ createLogger }) => { + const logger = createLogger('FeatureFlags') + if (isHosted) { + logger.error( + 'EXECUTE_COMMAND_ENABLED is set but ignored on hosted environment. Shell command execution is not available on hosted instances for security.' + ) + } else { + logger.warn( + 'EXECUTE_COMMAND_ENABLED is enabled. Workflows can execute arbitrary shell commands on the host machine. Only use this in trusted environments.' + ) + } + }) + .catch(() => { + // Fallback during config compilation when logger is unavailable + }) +} + /** * Are invitations disabled globally * When true, workspace invitations are disabled for all users diff --git a/apps/sim/tools/execute-command/execute.ts b/apps/sim/tools/execute-command/execute.ts new file mode 100644 index 00000000000..11e28f34ab9 --- /dev/null +++ b/apps/sim/tools/execute-command/execute.ts @@ -0,0 +1,122 @@ +import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' +import type { ExecuteCommandInput, ExecuteCommandOutput } from '@/tools/execute-command/types' +import type { ToolConfig } from '@/tools/types' + +export const executeCommandRunTool: ToolConfig = { + id: 'execute_command_run', + name: 'Execute Command', + description: + 'Execute a shell command on the host machine. Only available for self-hosted deployments with EXECUTE_COMMAND_ENABLED=true.', + version: '1.0.0', + + params: { + command: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The shell command to execute on the host machine', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Execution timeout in milliseconds', + default: DEFAULT_EXECUTION_TIMEOUT_MS, + }, + workingDirectory: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Working directory for the command. Defaults to the server process cwd.', + }, + envVars: { + type: 'object', + required: false, + visibility: 'hidden', + description: 'Environment variables to make available during execution', + default: {}, + }, + blockData: { + type: 'object', + required: false, + visibility: 'hidden', + description: 'Block output data for variable resolution', + default: {}, + }, + blockNameMapping: { + type: 'object', + required: false, + visibility: 'hidden', + description: 'Mapping of block names to block IDs', + default: {}, + }, + blockOutputSchemas: { + type: 'object', + required: false, + visibility: 'hidden', + description: 'Mapping of block IDs to their output schemas for validation', + default: {}, + }, + workflowVariables: { + type: 'object', + required: false, + visibility: 'hidden', + description: 'Workflow variables for resolution', + default: {}, + }, + }, + + request: { + url: '/api/tools/execute-command/run', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: ExecuteCommandInput) => ({ + command: params.command, + timeout: params.timeout || DEFAULT_EXECUTION_TIMEOUT_MS, + workingDirectory: params.workingDirectory, + envVars: params.envVars || {}, + workflowVariables: params.workflowVariables || {}, + blockData: params.blockData || {}, + blockNameMapping: params.blockNameMapping || {}, + blockOutputSchemas: params.blockOutputSchemas || {}, + workflowId: params._context?.workflowId, + }), + }, + + transformResponse: async (response: Response): Promise => { + const result = await response.json() + + if (!result.success) { + return { + success: false, + output: { + stdout: result.output?.stdout || '', + stderr: result.output?.stderr || '', + exitCode: result.output?.exitCode ?? 1, + }, + error: result.error, + } + } + + return { + success: true, + output: { + stdout: result.output.stdout, + stderr: result.output.stderr, + exitCode: result.output.exitCode, + }, + } + }, + + outputs: { + stdout: { type: 'string', description: 'Standard output from the command' }, + stderr: { type: 'string', description: 'Standard error output from the command' }, + exitCode: { type: 'number', description: 'Exit code of the command (0 = success)' }, + error: { + type: 'string', + description: 'Error message if the command timed out or exceeded buffer limits', + }, + }, +} diff --git a/apps/sim/tools/execute-command/index.ts b/apps/sim/tools/execute-command/index.ts new file mode 100644 index 00000000000..5160e940d87 --- /dev/null +++ b/apps/sim/tools/execute-command/index.ts @@ -0,0 +1,2 @@ +export { executeCommandRunTool } from '@/tools/execute-command/execute' +export type { ExecuteCommandInput, ExecuteCommandOutput } from '@/tools/execute-command/types' diff --git a/apps/sim/tools/execute-command/types.ts b/apps/sim/tools/execute-command/types.ts new file mode 100644 index 00000000000..e26a57f8269 --- /dev/null +++ b/apps/sim/tools/execute-command/types.ts @@ -0,0 +1,24 @@ +import type { ToolResponse } from '@/tools/types' + +export interface ExecuteCommandInput { + command: string + timeout?: number + workingDirectory?: string + envVars?: Record + workflowVariables?: Record + blockData?: Record + blockNameMapping?: Record + blockOutputSchemas?: Record> + _context?: { + workflowId?: string + userId?: string + } +} + +export interface ExecuteCommandOutput extends ToolResponse { + output: { + stdout: string + stderr: string + exitCode: number + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 8015599df29..681ef73427e 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -433,6 +433,7 @@ import { exaResearchTool, exaSearchTool, } from '@/tools/exa' +import { executeCommandRunTool } from '@/tools/execute-command' import { fileParserV2Tool, fileParserV3Tool, fileParseTool } from '@/tools/file' import { firecrawlAgentTool, @@ -2337,6 +2338,7 @@ export const tools: Record = { webhook_request: webhookRequestTool, huggingface_chat: huggingfaceChatTool, llm_chat: llmChatTool, + execute_command_run: executeCommandRunTool, function_execute: functionExecuteTool, gamma_generate: gammaGenerateTool, gamma_generate_from_template: gammaGenerateFromTemplateTool, diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index af6bbc10c3b..54683c8d4b9 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -206,6 +206,10 @@ app: DISABLE_PUBLIC_API: "" # Set to "true" to disable public API toggle globally NEXT_PUBLIC_DISABLE_PUBLIC_API: "" # Set to "true" to hide public API toggle in UI + # Execute Command (Self-Hosted Only - runs shell commands on host machine) + EXECUTE_COMMAND_ENABLED: "" # Set to "true" to enable shell command execution in workflows (self-hosted only) + NEXT_PUBLIC_EXECUTE_COMMAND_ENABLED: "" # Set to "true" to show Execute Command block in UI + # SSO Configuration (Enterprise Single Sign-On) # Set to "true" AFTER running the SSO registration script SSO_ENABLED: "" # Enable SSO authentication ("true" to enable)