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
Use this rule
npm install -D oxlint-plugin-mcp-sdk-security{ "jsPlugins": ["oxlint-plugin-mcp-sdk-security"], "rules": { "mcp-sdk-security/no-command-injection-in-tool": "error" }}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.