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
| Aspect | Details |
|---|---|
| CWE Reference | CWE-78 (OS Command Injection) |
| Severity | Critical (CVSS 9.8) |
| Auto-Fix | ❌ No auto-fix available |
| Category | Security |
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
},
);| Shape | This rule | node-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/forkbound tochild_process(named, default, namespace,require, orrequire('child_process').exec), the same wrapped inutil.promisify, andexeca/execaSync/execaCommand/execaCommandSync/execaNode.ISSUE_KEY.exec(key)is a RegExp anddb.exec(sql)is a database; neither is reported. - Tool arguments are the handler's first parameter (destructured or whole)
for
registerTool/tool, andrequest.params.argumentsforsetRequestHandler(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/letbindings 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 ZodSchema.parse(args)/safeParse(args).datahands the arguments back: parsing checks their shape, not their text. A key the parsing schema restricts toz.enum/z.literal/z.nativeEnumstays the allowlist. Callers are found by resolving the callee, never by a name. - A shell script position — the element after
-c,-lc,/cor-Commandwhen the file issh,bash,zsh,cmd.exe,powershell,pwsh… — is a command position too, and so is every argv element when the options setshell: true. - zx / execa
$templates quote each interpolation, so they are reported only where the argument chooses what runs: the first token (the binary), aftersh -c, or under$({ shell: true }). - A closed set is the allowlist. An argument whose input schema is
z.enum([...]),z.literal(...)orz.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], orcmd.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
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.