Root cause
Second AI origin
The separate HTTP/SSE server exposed the same command-building pattern to remotely supplied tool arguments.
How AI contributed: Direct introductionPublished Jun 19, 2026 in
ruvnet/agentic-flow
OS command injection (CWE-78) in agentic-flow MCP server tools: attacker-influenceable tool parameters (agent, task, name, language, agentdb, key, namespace, etc.) interpolated into shell command strings passed to execSync(), e.g. node "${cliPath}" --agent "${agent}" --task "${task}" in agentic_flow_agent; a value like x"; touch INJECTED; echo " breaks out of the quoted arg and executes arbitrary OS commands. Reachable via stdio/HTTP/SSE MCP transports; fixed in 2.0.14.
AI-assisted change
The restored stdio server built one shell string from MCP key and namespace values and executed it with execSync.
Root cause
The separate HTTP/SSE server exposed the same command-building pattern to remotely supplied tool arguments.
Fix
The fix changed both paths to pass every value as a distinct argv element to execFileSync with shell disabled.
AI-assisted fix: claude_flow · ruv
Case facts
diff --git a/agentic-flow/src/mcp/fastmcp/servers/stdio-full.ts b/agentic-flow/src/mcp/fastmcp/servers/stdio-full.tsnew file mode 100644--- /dev/null+++ b/agentic-flow/src/mcp/fastmcp/servers/stdio-full.ts@@ -0,0 +61,10 @@ selected from new file+ execute: async ({ key, namespace }) => {+ try {+ const cmd = `npx claude-flow@alpha memory retrieve "${key}" --namespace "${namespace}"`;+ const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });++ return JSON.stringify({+ success: true,+ key,+ namespace,+ value: result.trim(),diff --git a/agentic-flow/src/mcp/fastmcp/servers/http-sse.ts b/agentic-flow/src/mcp/fastmcp/servers/http-sse.tsnew file mode 100644--- /dev/null+++ b/agentic-flow/src/mcp/fastmcp/servers/http-sse.ts@@ -0,0 +64,36 @@ selected from new file+ }) => {+ try {+ // Build command with all parameters+ let cmd = `npx --yes agentic-flow --agent "${agent}" --task "${task}"`;++ // Provider & Model+ if (model) cmd += ` --model "${model}"`;+ if (provider) cmd += ` --provider ${provider}`;++ // API Keys (set as env vars)+ const env = { ...process.env };+ if (anthropicApiKey) env.ANTHROPIC_API_KEY = anthropicApiKey;+ if (openrouterApiKey) env.OPENROUTER_API_KEY = openrouterApiKey;++ // Agent Behavior+ if (stream) cmd += ' --stream';+ if (temperature !== undefined) cmd += ` --temperature ${temperature}`;+ if (maxTokens) cmd += ` --max-tokens ${maxTokens}`;++ // Directories+ if (agentsDir) cmd += ` --agents-dir "${agentsDir}"`;++ // Output+ if (outputFormat) cmd += ` --output ${outputFormat}`;+ if (verbose) cmd += ' --verbose';++ // Execution+ if (timeout) cmd += ` --timeout ${timeout}`;+ if (retryOnError) cmd += ' --retry';++ const result = execSync(cmd, {+ encoding: 'utf-8',+ maxBuffer: 10 * 1024 * 1024,+ timeout: timeout || 300000,+ env+ });diff --git a/agentic-flow/src/mcp/fastmcp/servers/stdio-full.ts b/agentic-flow/src/mcp/fastmcp/servers/stdio-full.ts--- a/agentic-flow/src/mcp/fastmcp/servers/stdio-full.ts+++ b/agentic-flow/src/mcp/fastmcp/servers/stdio-full.ts@@ -58,12 +59,15 @@ server.addTool({ key: z.string().min(1).describe('Memory key'), namespace: z.string().optional().default('default').describe('Memory namespace') }), execute: async ({ key, namespace }) => { try {- const cmd = `npx claude-flow@alpha memory retrieve "${key}" --namespace "${namespace}"`;- const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });+ const result = execFileSync(+ 'npx',+ ['claude-flow@alpha', 'memory', 'retrieve', key, '--namespace', namespace],+ { ...NPX_EXEC_OPTS, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }+ ); return JSON.stringify({ success: true, key, namespace,diff --git a/agentic-flow/src/mcp/fastmcp/servers/http-sse.ts b/agentic-flow/src/mcp/fastmcp/servers/http-sse.ts--- a/agentic-flow/src/mcp/fastmcp/servers/http-sse.ts+++ b/agentic-flow/src/mcp/fastmcp/servers/http-sse.ts@@ -61,39 +67,41 @@ server.addTool({ verbose, timeout, retryOnError }) => { try {- // Build command with all parameters- let cmd = `npx --yes agentic-flow --agent "${agent}" --task "${task}"`;+ // Build argv (NOT a shell string) — every user-supplied value stays a+ // separate argv element and cannot be reinterpreted by the shell.+ const args: string[] = ['--yes', 'agentic-flow', '--agent', agent, '--task', task]; // Provider & Model- if (model) cmd += ` --model "${model}"`;- if (provider) cmd += ` --provider ${provider}`;+ if (model) args.push('--model', model);+ if (provider) args.push('--provider', provider); // API Keys (set as env vars) const env = { ...process.env }; if (anthropicApiKey) env.ANTHROPIC_API_KEY = anthropicApiKey; if (openrouterApiKey) env.OPENROUTER_API_KEY = openrouterApiKey; // Agent Behavior- if (stream) cmd += ' --stream';- if (temperature !== undefined) cmd += ` --temperature ${temperature}`;- if (maxTokens) cmd += ` --max-tokens ${maxTokens}`;+ if (stream) args.push('--stream');+ if (temperature !== undefined) args.push('--temperature', String(temperature));+ if (maxTokens) args.push('--max-tokens', String(maxTokens)); // Directories- if (agentsDir) cmd += ` --agents-dir "${agentsDir}"`;+ if (agentsDir) args.push('--agents-dir', agentsDir); // Output- if (outputFormat) cmd += ` --output ${outputFormat}`;- if (verbose) cmd += ' --verbose';+ if (outputFormat) args.push('--output', outputFormat);+ if (verbose) args.push('--verbose'); // Execution- if (timeout) cmd += ` --timeout ${timeout}`;- if (retryOnError) cmd += ' --retry';+ if (timeout) args.push('--timeout', String(timeout));+ if (retryOnError) args.push('--retry'); - const result = execSync(cmd, {+ const result = execFileSync('npx', args, {+ ...NPX_EXEC_OPTS, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, timeout: timeout || 300000, env });