From 6802245a8a8a078126f351e3f22f1a7efeea8c14 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 12:33:53 -0800 Subject: [PATCH 01/15] feat(blocks): add execute command block for self-hosted shell execution --- apps/sim/.env.example | 4 + apps/sim/app/api/execute-command/run/route.ts | 320 ++++++++++++++++++ apps/sim/blocks/blocks/execute-command.ts | 53 +++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 28 ++ apps/sim/executor/constants.ts | 1 + .../execute-command-handler.ts | 52 +++ apps/sim/executor/handlers/index.ts | 2 + apps/sim/executor/handlers/registry.ts | 2 + apps/sim/lib/core/config/env.ts | 5 + apps/sim/lib/core/config/feature-flags.ts | 25 ++ apps/sim/tools/execute-command/execute.ts | 119 +++++++ apps/sim/tools/execute-command/index.ts | 2 + apps/sim/tools/execute-command/types.ts | 24 ++ apps/sim/tools/registry.ts | 2 + helm/sim/values.yaml | 4 + 16 files changed, 645 insertions(+) create mode 100644 apps/sim/app/api/execute-command/run/route.ts create mode 100644 apps/sim/blocks/blocks/execute-command.ts create mode 100644 apps/sim/executor/handlers/execute-command/execute-command-handler.ts create mode 100644 apps/sim/tools/execute-command/execute.ts create mode 100644 apps/sim/tools/execute-command/index.ts create mode 100644 apps/sim/tools/execute-command/types.ts 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/execute-command/run/route.ts b/apps/sim/app/api/execute-command/run/route.ts new file mode 100644 index 00000000000..d9437004140 --- /dev/null +++ b/apps/sim/app/api/execute-command/run/route.ts @@ -0,0 +1,320 @@ +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 { escapeRegExp, normalizeName, REFERENCE } 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 + +/** + * Resolves workflow variables () by replacing them with their actual values + */ +function resolveWorkflowVariables(command: string, workflowVariables: Record): string { + let resolved = command + const regex = createWorkflowVariablePattern() + let match: RegExpExecArray | null + const replacements: Array<{ match: string; index: number; value: string }> = [] + + while ((match = regex.exec(command)) !== null) { + const variableName = match[1].trim() + const foundVariable = Object.entries(workflowVariables).find( + ([_, variable]) => normalizeName(variable.name || '') === 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({ match: match[0], index: match.index, value }) + } + + for (let i = replacements.length - 1; i >= 0; i--) { + const { match: matchStr, index, value } = replacements[i] + resolved = resolved.slice(0, index) + value + resolved.slice(index + matchStr.length) + } + + return resolved +} + +/** + * Resolves environment variables ({{ENV_VAR}}) by replacing them with their actual values + */ +function resolveEnvironmentVariables(command: string, envVars: Record): string { + let resolved = command + const regex = createEnvVarPattern() + let match: RegExpExecArray | null + const replacements: Array<{ match: string; index: number; value: string }> = [] + + while ((match = regex.exec(command)) !== null) { + const varName = match[1].trim() + if (!(varName in envVars)) { + continue + } + replacements.push({ match: match[0], index: match.index, value: envVars[varName] }) + } + + for (let i = replacements.length - 1; i >= 0; i--) { + const { match: matchStr, index, value } = replacements[i] + resolved = resolved.slice(0, index) + value + resolved.slice(index + matchStr.length) + } + + return resolved +} + +/** + * Resolves block reference tags () by replacing them with their actual values + */ +function resolveTagVariables( + command: string, + blockData: Record, + blockNameMapping: Record, + blockOutputSchemas: Record +): string { + let resolved = command + + const tagPattern = new RegExp( + `${REFERENCE.START}([a-zA-Z_](?:[a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])?)${REFERENCE.END}`, + 'g' + ) + const tagMatches = resolved.match(tagPattern) || [] + + for (const match of tagMatches) { + const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).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) { + 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) + } + + resolved = resolved.replace(new RegExp(escapeRegExp(match), 'g'), stringValue) + } + + return resolved +} + +/** + * Resolves all variable references in a command string + */ +function resolveCommandVariables( + command: string, + envVars: Record, + blockData: Record, + blockNameMapping: Record, + blockOutputSchemas: Record, + workflowVariables: Record +): string { + let resolved = command + resolved = resolveWorkflowVariables(resolved, workflowVariables) + resolved = resolveEnvironmentVariables(resolved, envVars) + resolved = resolveTagVariables(resolved, blockData, blockNameMapping, blockOutputSchemas) + return resolved +} + +interface CommandResult { + stdout: string + stderr: string + exitCode: number + timedOut: 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: { ...process.env, ...options.env }, + }, + (error, stdout, stderr) => { + if (error) { + const killed = error.killed ?? false + const exitCode = typeof error.code === 'number' ? error.code : 1 + resolve({ + stdout: stdout.trimEnd(), + stderr: stderr.trimEnd(), + exitCode, + timedOut: killed, + }) + return + } + resolve({ + stdout: stdout.trimEnd(), + stderr: stderr.trimEnd(), + exitCode: 0, + timedOut: false, + }) + } + ) + + childProcess.on('error', (err) => { + resolve({ + stdout: '', + stderr: err.message, + exitCode: 1, + timedOut: 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 { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants') + + const { + command, + timeout = DEFAULT_EXECUTION_TIMEOUT_MS, + workingDirectory, + envVars = {}, + blockData = {}, + blockNameMapping = {}, + blockOutputSchemas = {}, + workflowVariables = {}, + workflowId, + } = body + + 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`, + }) + } + + 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..1a95309bf5e --- /dev/null +++ b/apps/sim/blocks/blocks/execute-command.ts @@ -0,0 +1,53 @@ +import { TerminalIcon } from '@/components/icons' +import { isTruthy } from '@/lib/core/config/env' +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. + - A non-zero exit code is treated as an error. Use || true to suppress errors if needed. + `, + 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!"', + }, + { + id: 'workingDirectory', + title: 'Working Directory', + type: 'short-input', + required: false, + placeholder: '/path/to/directory', + }, + ], + tools: { + access: ['execute_command_run'], + }, + inputs: { + command: { type: 'string', description: 'Shell command to execute' }, + workingDirectory: { type: 'string', description: 'Working directory for the command' }, + }, + 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)' }, + }, +} 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) { + throw new Error(result.error || 'Command execution failed') + } + + return result.output + } +} 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..2db5e4c32bd --- /dev/null +++ b/apps/sim/tools/execute-command/execute.ts @@ -0,0 +1,119 @@ +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: 'hidden', + 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/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, + userId: params._context?.userId, + }), + }, + + 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)' }, + }, +} 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) From 2a8aae68c5791ceeb76c662f7ad9cf50b092c6c7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 12:51:02 -0800 Subject: [PATCH 02/15] fix(execute-command): fix $-pattern substitution, maxBuffer vs timeout detection, bestPractices text --- apps/sim/app/api/execute-command/run/route.ts | 21 +++++++++++++++++-- apps/sim/blocks/blocks/execute-command.ts | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/execute-command/run/route.ts b/apps/sim/app/api/execute-command/run/route.ts index d9437004140..3d2ed49c0ad 100644 --- a/apps/sim/app/api/execute-command/run/route.ts +++ b/apps/sim/app/api/execute-command/run/route.ts @@ -131,7 +131,7 @@ function resolveTagVariables( stringValue = String(result.value) } - resolved = resolved.replace(new RegExp(escapeRegExp(match), 'g'), stringValue) + resolved = resolved.replace(new RegExp(escapeRegExp(match), 'g'), () => stringValue) } return resolved @@ -160,6 +160,7 @@ interface CommandResult { stderr: string exitCode: number timedOut: boolean + maxBufferExceeded: boolean } /** @@ -182,12 +183,14 @@ function executeCommand( (error, stdout, stderr) => { if (error) { const killed = error.killed ?? false + const isMaxBuffer = killed && /maxBuffer/.test(error.message ?? '') const exitCode = typeof error.code === 'number' ? error.code : 1 resolve({ stdout: stdout.trimEnd(), stderr: stderr.trimEnd(), exitCode, - timedOut: killed, + timedOut: killed && !isMaxBuffer, + maxBufferExceeded: isMaxBuffer, }) return } @@ -196,6 +199,7 @@ function executeCommand( stderr: stderr.trimEnd(), exitCode: 0, timedOut: false, + maxBufferExceeded: false, }) } ) @@ -206,6 +210,7 @@ function executeCommand( stderr: err.message, exitCode: 1, timedOut: false, + maxBufferExceeded: false, }) }) }) @@ -297,6 +302,18 @@ export async function POST(req: NextRequest) { }) } + 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: { diff --git a/apps/sim/blocks/blocks/execute-command.ts b/apps/sim/blocks/blocks/execute-command.ts index 1a95309bf5e..5890c978e60 100644 --- a/apps/sim/blocks/blocks/execute-command.ts +++ b/apps/sim/blocks/blocks/execute-command.ts @@ -16,7 +16,7 @@ export const ExecuteCommandBlock: BlockConfig = { - 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. - - A non-zero exit code is treated as an error. Use || true to suppress errors if needed. + - 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. `, docsLink: 'https://docs.sim.ai/blocks/execute-command', category: 'blocks', From cf67966e15abcd97949ef72314dd48b95b4c926f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 13:03:25 -0800 Subject: [PATCH 03/15] =?UTF-8?q?fix(execute-command):=20address=20review?= =?UTF-8?q?=20feedback=20=E2=80=94=20double-substitution,=20timeout:0,=20r?= =?UTF-8?q?oute=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite resolveTagVariables to use index-based back-to-front splicing instead of global regex replace, preventing double-substitution when resolved values contain tag-like patterns - Fix timeout:0 bypass by using explicit lower-bound check instead of destructuring default - Add shell injection warning to bestPractices documentation - Move API route from api/execute-command/ to api/tools/execute-command/ for consistency --- .../{ => tools}/execute-command/run/route.ts | 22 +++++++++++++------ apps/sim/blocks/blocks/execute-command.ts | 1 + apps/sim/tools/execute-command/execute.ts | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) rename apps/sim/app/api/{ => tools}/execute-command/run/route.ts (93%) diff --git a/apps/sim/app/api/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts similarity index 93% rename from apps/sim/app/api/execute-command/run/route.ts rename to apps/sim/app/api/tools/execute-command/run/route.ts index 3d2ed49c0ad..5ae1e4eaa53 100644 --- a/apps/sim/app/api/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -98,16 +98,16 @@ function resolveTagVariables( blockNameMapping: Record, blockOutputSchemas: Record ): string { - let resolved = command - const tagPattern = new RegExp( `${REFERENCE.START}([a-zA-Z_](?:[a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])?)${REFERENCE.END}`, 'g' ) - const tagMatches = resolved.match(tagPattern) || [] - for (const match of tagMatches) { - const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim() + const replacements: Array<{ match: string; index: number; value: string }> = [] + 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) @@ -131,7 +131,13 @@ function resolveTagVariables( stringValue = String(result.value) } - resolved = resolved.replace(new RegExp(escapeRegExp(match), 'g'), () => stringValue) + replacements.push({ match: match[0], index: match.index, value: stringValue }) + } + + let resolved = command + for (let i = replacements.length - 1; i >= 0; i--) { + const { match: matchStr, index, value } = replacements[i] + resolved = resolved.slice(0, index) + value + resolved.slice(index + matchStr.length) } return resolved @@ -243,7 +249,6 @@ export async function POST(req: NextRequest) { const { command, - timeout = DEFAULT_EXECUTION_TIMEOUT_MS, workingDirectory, envVars = {}, blockData = {}, @@ -253,6 +258,9 @@ export async function POST(req: NextRequest) { workflowId, } = body + const parsedTimeout = Number(body.timeout) + const timeout = parsedTimeout > 0 ? parsedTimeout : DEFAULT_EXECUTION_TIMEOUT_MS + if (!command || typeof command !== 'string') { return NextResponse.json( { success: false, error: 'Command is required and must be a string' }, diff --git a/apps/sim/blocks/blocks/execute-command.ts b/apps/sim/blocks/blocks/execute-command.ts index 5890c978e60..8a95e0b3081 100644 --- a/apps/sim/blocks/blocks/execute-command.ts +++ b/apps/sim/blocks/blocks/execute-command.ts @@ -17,6 +17,7 @@ export const ExecuteCommandBlock: BlockConfig = { - Use {{ENV_VAR}} syntax to reference environment variables. - The working directory defaults to the server process directory if not specified. - 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', diff --git a/apps/sim/tools/execute-command/execute.ts b/apps/sim/tools/execute-command/execute.ts index 2db5e4c32bd..3cb650ee67f 100644 --- a/apps/sim/tools/execute-command/execute.ts +++ b/apps/sim/tools/execute-command/execute.ts @@ -67,7 +67,7 @@ export const executeCommandRunTool: ToolConfig ({ 'Content-Type': 'application/json', From 24e1bfdf09eb4d92cff66584132a21f971227e03 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 13:05:10 -0800 Subject: [PATCH 04/15] fix(execute-command): return partial output on timeout/maxBuffer instead of throwing When a command times out or exceeds the buffer limit, the route returns partial stdout/stderr. Previously the handler threw an Error, discarding that output. Now returns partial output with an error field so downstream blocks can inspect it. Co-Authored-By: Claude Opus 4.6 --- .../handlers/execute-command/execute-command-handler.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/sim/executor/handlers/execute-command/execute-command-handler.ts b/apps/sim/executor/handlers/execute-command/execute-command-handler.ts index a1ef882a3aa..833a9cb8456 100644 --- a/apps/sim/executor/handlers/execute-command/execute-command-handler.ts +++ b/apps/sim/executor/handlers/execute-command/execute-command-handler.ts @@ -44,6 +44,12 @@ export class ExecuteCommandBlockHandler implements BlockHandler { ) if (!result.success) { + if (result.output) { + return { + ...result.output, + error: result.error || 'Command execution failed', + } + } throw new Error(result.error || 'Command execution failed') } From 5dc31b5cf9a0ddea6cbf7d3ed9faaaf14bf87ced Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 13:06:21 -0800 Subject: [PATCH 05/15] chore(execute-command): remove unused escapeRegExp import Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/api/tools/execute-command/run/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/tools/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts index 5ae1e4eaa53..45ef4018b65 100644 --- a/apps/sim/app/api/tools/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -4,7 +4,7 @@ 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 { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' +import { normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { createEnvVarPattern, From 0f293e85845c1f884907160a8b2e35f90c44be72 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 13:16:15 -0800 Subject: [PATCH 06/15] fix(execute-command): normalize both sides of variable name comparison, remove unused userId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix asymmetric normalization in resolveWorkflowVariables where the stored variable name was normalized but the reference name was only trimmed. This caused to fail matching a variable named "MyVar". Applied the same fix to the function route which had the identical bug. Also removed unused userId field from the execute-command tool config request body — auth identity comes from checkInternalAuth, not the body. Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/api/function/execute/route.ts | 2 +- apps/sim/app/api/tools/execute-command/run/route.ts | 2 +- apps/sim/tools/execute-command/execute.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) 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 index 45ef4018b65..d8ab5a3c286 100644 --- a/apps/sim/app/api/tools/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -31,7 +31,7 @@ function resolveWorkflowVariables(command: string, workflowVariables: Record normalizeName(variable.name || '') === variableName + ([_, variable]) => normalizeName(variable.name || '') === normalizeName(variableName) ) if (!foundVariable) { diff --git a/apps/sim/tools/execute-command/execute.ts b/apps/sim/tools/execute-command/execute.ts index 3cb650ee67f..9ea79b2b786 100644 --- a/apps/sim/tools/execute-command/execute.ts +++ b/apps/sim/tools/execute-command/execute.ts @@ -82,7 +82,6 @@ export const executeCommandRunTool: ToolConfig Date: Thu, 5 Mar 2026 13:24:56 -0800 Subject: [PATCH 07/15] fix(execute-command): sandbox child process env, fix maxBuffer detection - Replace `...process.env` with a safe base env containing only POSIX essentials (PATH, HOME, SHELL, etc). Server secrets like DATABASE_URL, AUTH_SECRET, API_ENCRYPTION_KEY are no longer inherited by commands. User-supplied envVars from the workflow are the additive layer. - Fix maxBuffer detection: Node.js only sets error.killed=true for timeout, NOT for maxBuffer exceeded. Check error.message independently so maxBuffer overflows are correctly detected and reported. Co-Authored-By: Claude Opus 4.6 --- .../api/tools/execute-command/run/route.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/tools/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts index d8ab5a3c286..9f1873f57e8 100644 --- a/apps/sim/app/api/tools/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -19,6 +19,22 @@ 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(): Record { + const env: Record = {} + for (const key of SAFE_ENV_KEYS) { + if (process.env[key]) { + env[key] = process.env[key]! + } + } + return env +} + /** * Resolves workflow variables () by replacing them with their actual values */ @@ -184,12 +200,12 @@ function executeCommand( timeout: options.timeout, cwd: options.cwd || undefined, maxBuffer: MAX_BUFFER, - env: { ...process.env, ...options.env }, + env: { ...getSafeBaseEnv(), ...options.env }, }, (error, stdout, stderr) => { if (error) { const killed = error.killed ?? false - const isMaxBuffer = killed && /maxBuffer/.test(error.message ?? '') + const isMaxBuffer = /maxBuffer/i.test(error.message ?? '') const exitCode = typeof error.code === 'number' ? error.code : 1 resolve({ stdout: stdout.trimEnd(), From 6774ad132bee4cbb41a50cb55cca7d3e53f9d567 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 13:28:57 -0800 Subject: [PATCH 08/15] feat(execute-command): add wandConfig for AI command generation Add AI wand configuration to the command subBlock, matching the pattern used by the Function block's code subBlock. Allows users to describe what they want in natural language and generate shell commands. Co-Authored-By: Claude Opus 4.6 --- apps/sim/blocks/blocks/execute-command.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/sim/blocks/blocks/execute-command.ts b/apps/sim/blocks/blocks/execute-command.ts index 8a95e0b3081..59a769ec08a 100644 --- a/apps/sim/blocks/blocks/execute-command.ts +++ b/apps/sim/blocks/blocks/execute-command.ts @@ -30,6 +30,27 @@ export const ExecuteCommandBlock: BlockConfig = { 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', From 8622ea434928a8755b5ae0cc315494eb590a7e86 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 13:30:29 -0800 Subject: [PATCH 09/15] fix(execute-command): prevent cascading variable resolution Refactor variable resolution to collect all replacements from all three patterns (workflow variables, env vars, block references) against the ORIGINAL command string, then apply them in a single back-to-front pass. Previously, three sequential passes meant a workflow variable value containing {{ENV_VAR}} syntax would be further resolved in the env var pass. Now all patterns are matched before any substitution occurs, preventing unintended cascading resolution. Co-Authored-By: Claude Opus 4.6 --- .../api/tools/execute-command/run/route.ts | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/api/tools/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts index 9f1873f57e8..f99f2b4e96e 100644 --- a/apps/sim/app/api/tools/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -35,14 +35,22 @@ function getSafeBaseEnv(): Record { return env } +interface Replacement { + index: number + length: number + value: string +} + /** - * Resolves workflow variables () by replacing them with their actual values + * Collects workflow variable () replacements from the original command string */ -function resolveWorkflowVariables(command: string, workflowVariables: Record): string { - let resolved = command +function collectWorkflowVariableReplacements( + command: string, + workflowVariables: Record +): Replacement[] { const regex = createWorkflowVariablePattern() + const replacements: Replacement[] = [] let match: RegExpExecArray | null - const replacements: Array<{ match: string; index: number; value: string }> = [] while ((match = regex.exec(command)) !== null) { const variableName = match[1].trim() @@ -69,57 +77,49 @@ function resolveWorkflowVariables(command: string, workflowVariables: Record= 0; i--) { - const { match: matchStr, index, value } = replacements[i] - resolved = resolved.slice(0, index) + value + resolved.slice(index + matchStr.length) + replacements.push({ index: match.index, length: match[0].length, value }) } - return resolved + return replacements } /** - * Resolves environment variables ({{ENV_VAR}}) by replacing them with their actual values + * Collects environment variable ({{ENV_VAR}}) replacements from the original command string */ -function resolveEnvironmentVariables(command: string, envVars: Record): string { - let resolved = command +function collectEnvVarReplacements( + command: string, + envVars: Record +): Replacement[] { const regex = createEnvVarPattern() + const replacements: Replacement[] = [] let match: RegExpExecArray | null - const replacements: Array<{ match: string; index: number; value: string }> = [] while ((match = regex.exec(command)) !== null) { const varName = match[1].trim() if (!(varName in envVars)) { continue } - replacements.push({ match: match[0], index: match.index, value: envVars[varName] }) - } - - for (let i = replacements.length - 1; i >= 0; i--) { - const { match: matchStr, index, value } = replacements[i] - resolved = resolved.slice(0, index) + value + resolved.slice(index + matchStr.length) + replacements.push({ index: match.index, length: match[0].length, value: envVars[varName] }) } - return resolved + return replacements } /** - * Resolves block reference tags () by replacing them with their actual values + * Collects block reference tag () replacements from the original command string */ -function resolveTagVariables( +function collectTagReplacements( command: string, blockData: Record, blockNameMapping: Record, blockOutputSchemas: Record -): string { +): Replacement[] { const tagPattern = new RegExp( `${REFERENCE.START}([a-zA-Z_](?:[a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])?)${REFERENCE.END}`, 'g' ) - const replacements: Array<{ match: string; index: number; value: string }> = [] + const replacements: Replacement[] = [] let match: RegExpExecArray | null while ((match = tagPattern.exec(command)) !== null) { @@ -147,20 +147,17 @@ function resolveTagVariables( stringValue = String(result.value) } - replacements.push({ match: match[0], index: match.index, value: stringValue }) - } - - let resolved = command - for (let i = replacements.length - 1; i >= 0; i--) { - const { match: matchStr, index, value } = replacements[i] - resolved = resolved.slice(0, index) + value + resolved.slice(index + matchStr.length) + replacements.push({ index: match.index, length: match[0].length, value: stringValue }) } - return resolved + return replacements } /** - * Resolves all variable references in a command string + * 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, @@ -170,10 +167,20 @@ function resolveCommandVariables( 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) + let resolved = command - resolved = resolveWorkflowVariables(resolved, workflowVariables) - resolved = resolveEnvironmentVariables(resolved, envVars) - resolved = resolveTagVariables(resolved, blockData, blockNameMapping, blockOutputSchemas) + 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 } From 281f5bb213b21e9d9375cffb058298c30cf144bc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 13:41:44 -0800 Subject: [PATCH 10/15] fix(execute-command): fix ProcessEnv type for child process env Return NodeJS.ProcessEnv from getSafeBaseEnv() and include NODE_ENV to satisfy the exec() type signature which requires ProcessEnv. Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/api/tools/execute-command/run/route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/tools/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts index f99f2b4e96e..b0d956bc423 100644 --- a/apps/sim/app/api/tools/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -25,11 +25,11 @@ const SAFE_ENV_KEYS = ['PATH', 'HOME', 'SHELL', 'USER', 'LOGNAME', 'LANG', 'TERM * Returns a minimal base environment for child processes. * Only includes POSIX essentials — never server secrets like DATABASE_URL, AUTH_SECRET, etc. */ -function getSafeBaseEnv(): Record { - const env: Record = {} +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]! + env[key] = process.env[key] } } return env From 3d0b07b5551126e4a3f721cc882654bd765a6756 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 13:47:24 -0800 Subject: [PATCH 11/15] fix(execute-command): throw on unresolved block refs, block env var override - Throw a clear error when a block reference cannot be resolved instead of leaving raw in the command (which the shell interprets as I/O redirection). Skip special prefixes (variable, loop, parallel) which are handled by their own collectors. - Filter user-supplied env vars to prevent overriding safe base env keys (PATH, HOME, SHELL, etc.) and block process-influencing variables (LD_PRELOAD, BASH_ENV, DYLD_INSERT_LIBRARIES, etc.). Co-Authored-By: Claude Opus 4.6 --- .../api/tools/execute-command/run/route.ts | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/tools/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts index b0d956bc423..b7491c33483 100644 --- a/apps/sim/app/api/tools/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -4,7 +4,7 @@ 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 { normalizeName, REFERENCE } from '@/executor/constants' +import { normalizeName, REFERENCE, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { createEnvVarPattern, @@ -35,6 +35,31 @@ function getSafeBaseEnv(): NodeJS.ProcessEnv { return env } +const BLOCKED_ENV_KEYS = new Set([ + ...SAFE_ENV_KEYS, + 'NODE_ENV', + 'LD_PRELOAD', + 'LD_LIBRARY_PATH', + 'DYLD_INSERT_LIBRARIES', + 'BASH_ENV', + 'ENV', +]) + +/** + * 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 @@ -135,6 +160,12 @@ function collectTagReplacements( }) 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 } @@ -207,7 +238,7 @@ function executeCommand( timeout: options.timeout, cwd: options.cwd || undefined, maxBuffer: MAX_BUFFER, - env: { ...getSafeBaseEnv(), ...options.env }, + env: { ...getSafeBaseEnv(), ...filterUserEnv(options.env) }, }, (error, stdout, stderr) => { if (error) { From 7b7ab8d2c271a1186baf3ccde803d1368beb4da0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 14:16:25 -0800 Subject: [PATCH 12/15] fix(execute-command): extend env blocklist, add error output, fix tag regex - Extend BLOCKED_ENV_KEYS with NODE_OPTIONS, LD_AUDIT, DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH, GLIBC_TUNABLES, NODE_PATH, PYTHONPATH, PERL5LIB, RUBYLIB to prevent process-influencing env var injection. - Add error field to block and tool output schemas so downstream blocks can inspect timeout/buffer errors via . Handler returns error: null on success for consistency. - Use permissive tag regex ([^<>]+) matching createReferencePattern() so block names with hyphens (e.g. ) resolve correctly. Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/api/tools/execute-command/run/route.ts | 11 ++++++++++- apps/sim/blocks/blocks/execute-command.ts | 4 ++++ .../execute-command/execute-command-handler.ts | 2 +- apps/sim/tools/execute-command/execute.ts | 4 ++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/tools/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts index b7491c33483..995ed0a5b90 100644 --- a/apps/sim/app/api/tools/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -38,11 +38,20 @@ function getSafeBaseEnv(): NodeJS.ProcessEnv { 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', + 'RUBYLIB', ]) /** @@ -140,7 +149,7 @@ function collectTagReplacements( blockOutputSchemas: Record ): Replacement[] { const tagPattern = new RegExp( - `${REFERENCE.START}([a-zA-Z_](?:[a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])?)${REFERENCE.END}`, + `${REFERENCE.START}([^${REFERENCE.START}${REFERENCE.END}]+)${REFERENCE.END}`, 'g' ) diff --git a/apps/sim/blocks/blocks/execute-command.ts b/apps/sim/blocks/blocks/execute-command.ts index 59a769ec08a..28d7ac3befb 100644 --- a/apps/sim/blocks/blocks/execute-command.ts +++ b/apps/sim/blocks/blocks/execute-command.ts @@ -71,5 +71,9 @@ IMPORTANT FORMATTING RULES: 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/executor/handlers/execute-command/execute-command-handler.ts b/apps/sim/executor/handlers/execute-command/execute-command-handler.ts index 833a9cb8456..9ca201bb4e9 100644 --- a/apps/sim/executor/handlers/execute-command/execute-command-handler.ts +++ b/apps/sim/executor/handlers/execute-command/execute-command-handler.ts @@ -53,6 +53,6 @@ export class ExecuteCommandBlockHandler implements BlockHandler { throw new Error(result.error || 'Command execution failed') } - return result.output + return { ...result.output, error: null } } } diff --git a/apps/sim/tools/execute-command/execute.ts b/apps/sim/tools/execute-command/execute.ts index 9ea79b2b786..5c394292339 100644 --- a/apps/sim/tools/execute-command/execute.ts +++ b/apps/sim/tools/execute-command/execute.ts @@ -114,5 +114,9 @@ export const executeCommandRunTool: ToolConfig Date: Thu, 5 Mar 2026 14:32:28 -0800 Subject: [PATCH 13/15] fix(execute-command): add JAVA_TOOL_OPTIONS and PERL5OPT to env blocklist Add two more process-influencing env vars to the blocklist: - JAVA_TOOL_OPTIONS: allows JVM agent injection (similar to NODE_OPTIONS) - PERL5OPT: allows Perl code injection (complements PERL5LIB) Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/api/tools/execute-command/run/route.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/sim/app/api/tools/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts index 995ed0a5b90..e00dea0c5f2 100644 --- a/apps/sim/app/api/tools/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -51,7 +51,9 @@ const BLOCKED_ENV_KEYS = new Set([ 'NODE_PATH', 'PYTHONPATH', 'PERL5LIB', + 'PERL5OPT', 'RUBYLIB', + 'JAVA_TOOL_OPTIONS', ]) /** From 5782b6830ef8acb2939f791d12ba1a85de33bed6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 5 Mar 2026 14:48:50 -0800 Subject: [PATCH 14/15] fix(execute-command): static import, user-configurable timeout, overlap guard, shell-safe regex - Promote DEFAULT_EXECUTION_TIMEOUT_MS to static top-level import - Add timeout subBlock so users can configure command timeout - Add overlapping replacement assertion to prevent corruption - Tighten tag regex to require non-whitespace start, avoiding shell redirection false matches Co-Authored-By: Claude Opus 4.6 --- .../sim/app/api/tools/execute-command/run/route.ts | 14 ++++++++++++-- apps/sim/blocks/blocks/execute-command.ts | 9 +++++++++ apps/sim/tools/execute-command/execute.ts | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/tools/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts index e00dea0c5f2..5d5216ae130 100644 --- a/apps/sim/app/api/tools/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -4,6 +4,7 @@ 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 { @@ -151,7 +152,7 @@ function collectTagReplacements( blockOutputSchemas: Record ): Replacement[] { const tagPattern = new RegExp( - `${REFERENCE.START}([^${REFERENCE.START}${REFERENCE.END}]+)${REFERENCE.END}`, + `${REFERENCE.START}(\\S[^${REFERENCE.START}${REFERENCE.END}]*)${REFERENCE.END}`, 'g' ) @@ -217,6 +218,16 @@ function resolveCommandVariables( 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] @@ -310,7 +321,6 @@ export async function POST(req: NextRequest) { } const body = await req.json() - const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants') const { command, diff --git a/apps/sim/blocks/blocks/execute-command.ts b/apps/sim/blocks/blocks/execute-command.ts index 28d7ac3befb..cd7b65fcf76 100644 --- a/apps/sim/blocks/blocks/execute-command.ts +++ b/apps/sim/blocks/blocks/execute-command.ts @@ -1,5 +1,6 @@ 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' @@ -59,6 +60,13 @@ IMPORTANT FORMATTING RULES: 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'], @@ -66,6 +74,7 @@ IMPORTANT FORMATTING RULES: 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' }, diff --git a/apps/sim/tools/execute-command/execute.ts b/apps/sim/tools/execute-command/execute.ts index 5c394292339..11e28f34ab9 100644 --- a/apps/sim/tools/execute-command/execute.ts +++ b/apps/sim/tools/execute-command/execute.ts @@ -19,7 +19,7 @@ export const executeCommandRunTool: ToolConfig Date: Thu, 5 Mar 2026 15:14:15 -0800 Subject: [PATCH 15/15] fix(execute-command): cap timeout at MAX_DURATION, simplify error handler, document workingDirectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enforce upper bound on timeout (MAX_DURATION - 10s) to prevent orphan processes - Remove unreachable throw branch in handler — always return structured data - Document that workingDirectory does not support variable references Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/api/tools/execute-command/run/route.ts | 6 +++++- apps/sim/blocks/blocks/execute-command.ts | 2 +- .../handlers/execute-command/execute-command-handler.ts | 9 +++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/api/tools/execute-command/run/route.ts b/apps/sim/app/api/tools/execute-command/run/route.ts index 5d5216ae130..435461f1b4f 100644 --- a/apps/sim/app/api/tools/execute-command/run/route.ts +++ b/apps/sim/app/api/tools/execute-command/run/route.ts @@ -333,8 +333,12 @@ export async function POST(req: NextRequest) { workflowId, } = body + const MAX_ALLOWED_TIMEOUT_MS = (MAX_DURATION - 10) * 1000 const parsedTimeout = Number(body.timeout) - const timeout = parsedTimeout > 0 ? parsedTimeout : DEFAULT_EXECUTION_TIMEOUT_MS + const timeout = Math.min( + parsedTimeout > 0 ? parsedTimeout : DEFAULT_EXECUTION_TIMEOUT_MS, + MAX_ALLOWED_TIMEOUT_MS + ) if (!command || typeof command !== 'string') { return NextResponse.json( diff --git a/apps/sim/blocks/blocks/execute-command.ts b/apps/sim/blocks/blocks/execute-command.ts index cd7b65fcf76..311eed0c76a 100644 --- a/apps/sim/blocks/blocks/execute-command.ts +++ b/apps/sim/blocks/blocks/execute-command.ts @@ -16,7 +16,7 @@ export const ExecuteCommandBlock: BlockConfig = { - 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. + - 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. `, diff --git a/apps/sim/executor/handlers/execute-command/execute-command-handler.ts b/apps/sim/executor/handlers/execute-command/execute-command-handler.ts index 9ca201bb4e9..237a7caaec6 100644 --- a/apps/sim/executor/handlers/execute-command/execute-command-handler.ts +++ b/apps/sim/executor/handlers/execute-command/execute-command-handler.ts @@ -44,13 +44,10 @@ export class ExecuteCommandBlockHandler implements BlockHandler { ) if (!result.success) { - if (result.output) { - return { - ...result.output, - error: result.error || 'Command execution failed', - } + return { + ...(result.output ?? { stdout: '', stderr: '', exitCode: 1 }), + error: result.error || 'Command execution failed', } - throw new Error(result.error || 'Command execution failed') } return { ...result.output, error: null }