Skip to main content
interlace
Plugin: mcp-sdk-securityRules

no-command-injection-in-tool

Disallow an MCP tool argument choosing the command a child_process call runs.

CWE: CWE-78 OWASP: A03:2021 – Injection

Detects a tool handler's own parameter — used as, built into, or derived into the command — reaching exec, spawn, execFile, fork, execa or a zx / execa $ template. This rule is part of eslint-plugin-mcp-sdk-security.

💼 This rule is set to error in the minimal, recommended and strict configs.

Quick Summary

AspectDetails
CWE ReferenceCWE-78 (OS Command Injection)
SeverityCritical (CVSS 9.8)
Auto-Fix❌ No auto-fix available
CategorySecurity

Why this matters

A tool handler's parameter is attacker-influenced by construction. It is filled from the model's tool call, and the model can be steered by any content it has read — a web page it fetched, a file it opened, another tool's output. Treating it as trusted is the MCP equivalent of trusting req.body, except the caller is a language model that an attacker may be writing the inputs for.

When that value names the command, whoever steers the model chooses what runs on the host.

Relationship to node-security/no-shell-injection

no-shell-injection is shape-based: it reports exec(`git ${cmd}`) because the interpolation is visible, and declines exec(cmd) because proving what cmd holds needs value following. Inside an MCP tool handler the source is known — the handler's own parameter — so this rule follows it and reports every shape that lets it choose what runs:

server.registerTool(
  'run',
  { inputSchema: { cmd: z.string() } },
  async ({ cmd }) => {
    execSync(cmd); // the argument IS the command
    execSync(`ls ${cmd}`); // built from it
    const c = cmd.trim();
    execSync(c); // derived from it
  },
);
ShapeThis rulenode-security/no-shell-injection
execSync(cmd) — argument is the command✅—
execSync(`ls ${cmd}`) — built from the argument✅✅
const c = cmd.trim(); execSync(c) — derived from it✅—
execSync('ls -la') — static——

On the interpolated line both plugins may report. Tool handlers are this plugin's own scope, so it reports them rather than leave a gap for servers that do not install node-security (owner decision, 2026-10).

❌ Incorrect

// ❌ destructured argument names the command
server.registerTool('run', cfg, async ({ cmd }) => {
  execSync(cmd);
});

// ❌ same thing through the whole-args object
server.registerTool('run', cfg, async (args) => {
  execSync(args.cmd);
});

// ❌ spawn picks the binary too — the argv array does not help here
server.registerTool('run', cfg, async ({ bin }) => {
  spawn(bin, argv);
});

// ❌ a shell's `-c` script is a command line
server.registerTool('run', cfg, async ({ command }) => {
  spawn('sh', ['-c', command]);
});

// ❌ promisified, renamed, from execa, or behind a named handler — same sink
const execAsync = promisify(exec);
async function runHandler({ cmd }) {
  await execAsync(cmd);
  await execaCommand(cmd);
}
server.registerTool('run', cfg, runHandler);

// ❌ the low-level Server: the arguments are request.params.arguments
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { arguments: args } = request.params;
  execSync(String(args?.cmd));
});

How sinks and arguments are recognised

  • Sinks are resolved to their module, never matched by name: an exec/execSync/execFile/execFileSync/spawn/spawnSync/fork bound to child_process (named, default, namespace, require, or require('child_process').exec), the same wrapped in util.promisify, and execa/execaSync/execaCommand/execaCommandSync/execaNode. ISSUE_KEY.exec(key) is a RegExp and db.exec(sql) is a database; neither is reported.
  • Tool arguments are the handler's first parameter (destructured or whole) for registerTool/tool, and request.params.arguments for setRequestHandler(CallToolRequestSchema) / setRequestHandler('tools/call'). Each reference is resolved through the scope manager, so a callback parameter that shadows an argument is a different variable.
  • The argument's value is followed within the file, bounded and cycle-guarded: through const/let bindings and reassignments, destructuring, member and element reads, await, string derivations (.trim(), .split(), .slice(), .toLowerCase(), .join(), String(), template literals, +) and the return value of a same-file helper.
  • Into helpers, elements and parsed arguments (1.0). A same-file function or class method (this.run(args.cmd)) the handler calls carries what its callers pass it, so a sink inside the helper is reported — an object literal it is given is read field by field. Each element of an argument array is the argument (for (const p of paths), paths.map((p) => …)), and a Zod Schema.parse(args) / safeParse(args).data hands the arguments back: parsing checks their shape, not their text. A key the parsing schema restricts to z.enum / z.literal / z.nativeEnum stays the allowlist. Callers are found by resolving the callee, never by a name.
  • A shell script position — the element after -c, -lc, /c or -Command when the file is sh, bash, zsh, cmd.exe, powershell, pwsh… — is a command position too, and so is every argv element when the options set shell: true.
  • zx / execa $ templates quote each interpolation, so they are reported only where the argument chooses what runs: the first token (the binary), after sh -c, or under $({ shell: true }).
  • A closed set is the allowlist. An argument whose input schema is z.enum([...]), z.literal(...) or z.nativeEnum(...) is not reported: the SDK rejects anything outside the set before the handler runs.

✅ Correct

// ✅ the argument selects an operation; the code names the binary
const ALLOWED = { list: 'ls', disk: 'df' } as const;

server.registerTool('run', cfg, async ({ op, target }) => {
  const binary = ALLOWED[op];
  if (!binary) throw new Error('unsupported operation');
  execFile(binary, [target]); // user data is an argv element, never the command
});

Two things make that safe, and both are needed: the binary comes from a closed set the code owns, and the user-supplied value is passed as an argv array element rather than spliced into a command line. execFile with an array does not involve a shell, so there is no metacharacter to escape.

What this rule deliberately does not report

  • The whole args object. execSync(args) is not a command; only a member of it (args.cmd) is.
  • A variable that is not a tool argument. execSync(configuredBinary) may well be unsafe, but this rule cannot show it came from the model, and guessing is what earns a security rule its false-positive reputation.
  • A computed member. args[key] is not statically a name.
  • A value the argument only selects. execSync(lookup(cmd)), ALLOWED[cmd], or cmd.length > 3 ? 'ls' : 'pwd' — an arbitrary call, a lookup or a comparison yields a value the code chose, not the argument's text.
  • A sink no tool argument reaches — outside every handler and every same-file helper a handler passes an argument to. That is node-security's question.
  • A file that imports no MCP server package (@modelcontextprotocol/sdk, the v2 @modelcontextprotocol/* packages, mcp-handler).

When Not To Use It

There is no configuration in which letting a model-supplied value name the command is correct, so this rule has no options.

Further Reading

Use this rule

npm install -D oxlint-plugin-mcp-sdk-security
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-mcp-sdk-security"],  "rules": {    "mcp-sdk-security/no-command-injection-in-tool": "error"  }}
vite.config.ts
import { defineConfig } from 'vite-plus';export default defineConfig({  lint: {    jsPlugins: ['oxlint-plugin-mcp-sdk-security'],    rules: {      'mcp-sdk-security/no-command-injection-in-tool': 'error',    },  },});

oxlint-plugin-mcp-sdk-security re-exports eslint-plugin-mcp-sdk-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.