Compare commits

...

15 Commits

Author SHA1 Message Date
Waleed Latif
448548ec31 fix(execute-command): cap timeout at MAX_DURATION, simplify error handler, document workingDirectory
- 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 <noreply@anthropic.com>
2026-03-05 15:14:15 -08:00
Waleed Latif
5782b6830e 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 <noreply@anthropic.com>
2026-03-05 14:48:50 -08:00
Waleed Latif
c4cb2aaf06 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 <noreply@anthropic.com>
2026-03-05 14:32:28 -08:00
Waleed Latif
7b7ab8d2c2 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 <executeCommand.error>. Handler
  returns error: null on success for consistency.

- Use permissive tag regex ([^<>]+) matching createReferencePattern() so
  block names with hyphens (e.g. <my-block.output>) resolve correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:16:25 -08:00
Waleed Latif
3d0b07b555 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 <blockName.output> 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 <noreply@anthropic.com>
2026-03-05 13:47:24 -08:00
Waleed Latif
281f5bb213 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 <noreply@anthropic.com>
2026-03-05 13:41:44 -08:00
Waleed Latif
8622ea4349 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 <noreply@anthropic.com>
2026-03-05 13:30:29 -08:00
Waleed Latif
6774ad132b 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 <noreply@anthropic.com>
2026-03-05 13:28:57 -08:00
Waleed Latif
94679a6e55 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 <noreply@anthropic.com>
2026-03-05 13:24:56 -08:00
Waleed Latif
0f293e8584 fix(execute-command): normalize both sides of variable name comparison, remove unused userId
Fix asymmetric normalization in resolveWorkflowVariables where the stored
variable name was normalized but the reference name was only trimmed. This
caused <variable.MyVar> 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 <noreply@anthropic.com>
2026-03-05 13:16:15 -08:00
Waleed Latif
5dc31b5cf9 chore(execute-command): remove unused escapeRegExp import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:06:21 -08:00
Waleed Latif
24e1bfdf09 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 <noreply@anthropic.com>
2026-03-05 13:05:10 -08:00
Waleed Latif
cf67966e15 fix(execute-command): address review feedback — double-substitution, timeout:0, route path
- 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
2026-03-05 13:03:25 -08:00
Waleed Latif
2a8aae68c5 fix(execute-command): fix $-pattern substitution, maxBuffer vs timeout detection, bestPractices text 2026-03-05 12:51:02 -08:00
Waleed Latif
6802245a8a feat(blocks): add execute command block for self-hosted shell execution 2026-03-05 12:35:14 -08:00
17 changed files with 791 additions and 1 deletions

View File

@@ -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

View File

@@ -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) {

View File

@@ -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<string, string>): Record<string, string> {
if (!env) return {}
const filtered: Record<string, string> = {}
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 (<variable.name>) replacements from the original command string
*/
function collectWorkflowVariableReplacements(
command: string,
workflowVariables: Record<string, any>
): 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<string, string>
): 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 (<blockName.field>) replacements from the original command string
*/
function collectTagReplacements(
command: string,
blockData: Record<string, unknown>,
blockNameMapping: Record<string, string>,
blockOutputSchemas: Record<string, OutputSchema>
): 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<string, string>,
blockData: Record<string, unknown>,
blockNameMapping: Record<string, string>,
blockOutputSchemas: Record<string, OutputSchema>,
workflowVariables: Record<string, unknown>
): 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<string, string> }
): Promise<CommandResult> {
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 }
)
}
}

View File

@@ -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<ExecuteCommandOutput> = {
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 <blockName.output> 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: <blockName.output>
- 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 <blockName.field> 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',
},
},
}

View File

@@ -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<string, BlockConfig> = {
elevenlabs: ElevenLabsBlock,
enrich: EnrichBlock,
evaluator: EvaluatorBlock,
execute_command: ExecuteCommandBlock,
exa: ExaBlock,
file: FileBlock,
file_v2: FileV2Block,

View File

@@ -367,6 +367,34 @@ export function CodeIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TerminalIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M4 17L10 11L4 5'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M12 19H20'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}
export function ChartBarIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -24,6 +24,7 @@ export enum BlockType {
TRIGGER = 'trigger',
FUNCTION = 'function',
EXECUTE_COMMAND = 'execute_command',
AGENT = 'agent',
API = 'api',
EVALUATOR = 'evaluator',

View File

@@ -0,0 +1,55 @@
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
/**
* Handler for Execute Command blocks that run shell commands on the host machine.
*/
export class ExecuteCommandBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.EXECUTE_COMMAND
}
async execute(
ctx: ExecutionContext,
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
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 }
}
}

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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

View File

@@ -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<ExecuteCommandInput, ExecuteCommandOutput> = {
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 <variable.name> 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<ExecuteCommandOutput> => {
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',
},
},
}

View File

@@ -0,0 +1,2 @@
export { executeCommandRunTool } from '@/tools/execute-command/execute'
export type { ExecuteCommandInput, ExecuteCommandOutput } from '@/tools/execute-command/types'

View File

@@ -0,0 +1,24 @@
import type { ToolResponse } from '@/tools/types'
export interface ExecuteCommandInput {
command: string
timeout?: number
workingDirectory?: string
envVars?: Record<string, string>
workflowVariables?: Record<string, unknown>
blockData?: Record<string, unknown>
blockNameMapping?: Record<string, string>
blockOutputSchemas?: Record<string, Record<string, unknown>>
_context?: {
workflowId?: string
userId?: string
}
}
export interface ExecuteCommandOutput extends ToolResponse {
output: {
stdout: string
stderr: string
exitCode: number
}
}

View File

@@ -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<string, ToolConfig> = {
webhook_request: webhookRequestTool,
huggingface_chat: huggingfaceChatTool,
llm_chat: llmChatTool,
execute_command_run: executeCommandRunTool,
function_execute: functionExecuteTool,
gamma_generate: gammaGenerateTool,
gamma_generate_from_template: gammaGenerateFromTemplateTool,

View File

@@ -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)