shell-subprocess-safety

star 2

Shell subprocess safety patterns - execFileSync over execSync, array arguments, shell feature replacements, static analysis guards. Use when spawning child processes, running git/docker/system commands, or reviewing code that calls execSync.

kookr-ai By kookr-ai schedule Updated 5/5/2026

name: shell-subprocess-safety description: Shell subprocess safety patterns - execFileSync over execSync, array arguments, shell feature replacements, static analysis guards. Use when spawning child processes, running git/docker/system commands, or reviewing code that calls execSync. keywords: execSync, execFileSync, spawnSync, shell injection, command injection, child_process, CWE-78, shellQuote, escapeShellArg, template literal, subprocess, git command, docker command related: error-handling-patterns, process-lifecycle-patterns, testing-patterns

Shell Subprocess Safety

When to Use

  • Writing code that calls external binaries (git, docker, lsof, grep, etc.)
  • Reviewing code that uses execSync(), child_process, or shell commands
  • Migrating existing shell-interpolated commands to safe patterns
  • Adding new system commands to production code or scripts

Non-Negotiable Rules

# Rule Violation Correct
1 Never execSync with interpolation execSync(`git checkout ${branch}`) execFileSync('git', ['checkout', branch])
2 Never execSync with concatenation execSync('git checkout ' + branch) execFileSync('git', ['checkout', branch])
3 No shell quoting helpers shellQuote(val), escapeShellArg(val) Not needed — execFileSync bypasses shell
4 Array args for all binaries execFileSync('git', 'checkout main') execFileSync('git', ['checkout', 'main'])
5 Validate untrusted input Pass raw user string to binary Validate format (hex SHA, port range) before exec

Shell Feature Replacements

Shell Feature Node.js Replacement
cmd 2>/dev/null execFileSync(cmd, args, { stdio: ['pipe', 'pipe', 'ignore'] })
cmd || true try { execFileSync(...) } catch { /* handle or ignore */ }
cmd1 | cmd2 spawnSync(cmd2, args2, { input: execFileSync(cmd1, args1) })
cat file | cmd execFileSync(cmd, args, { input: readFileSync(file) })
echo "text" Return string directly or Buffer.from(text)
sleep N Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, N * 1000)

Wrapper Method Pattern

// CORRECT: Private wrapper accepts string[] — all call sites pass arrays
private git(args: string[]): string {
    return execFileSync('git', args, {
        cwd: this.workingDir,
        stdio: 'pipe',
        timeout: 30_000,
        encoding: 'utf-8',
    }).trim();
}

// Usage — no shell interpretation of metacharacters
this.git(['checkout', branchName]);
this.git(['merge', '--no-ff', '-m', commitMessage, sourceBranch]);
this.git(['checkout', '--theirs', '--', filePath]);

Input Validation Patterns

// SHA validation — hex characters only
function assertSha(sha: string): void {
    if (!/^[0-9a-f]{7,40}$/i.test(sha)) {
        throw new Error(`Invalid SHA: ${sha}`);
    }
}

// Port validation — numeric range
function assertPort(port: number): void {
    if (!Number.isInteger(port) || port < 1 || port > 65535) {
        throw new Error(`Invalid port: ${port}`);
    }
}

Static Analysis Guard

Detect violations by grepping for the two injection-prone shapes (execSync with a template literal; execSync with string concatenation):

grep -rn 'execSync(`' src/
grep -rn 'execSync(.*+' src/

Add the checks to CI or a pre-commit hook to prevent regression.

Common Pitfalls

Pitfall Why It Fails Fix
execFileSync('git', 'checkout main') Second arg is string not array Always pass string[]
execFileSync('sh', ['-c', cmd]) Still spawns shell — defeats purpose Call binary directly
execSync(staticString) without interpolation OK but inconsistent Prefer execFileSync for consistency
Forgetting encoding: 'utf-8' Returns Buffer not string Always pass encoding option
Not handling exit code 1 grep returns 1 for no matches Wrap in try/catch, check status

Cross-Platform Portability (Linux ↔ macOS)

A binary that exists on both platforms may still take different flags. macOS ships BSD coreutils and bash 3.2 (frozen at GPLv2), so GNU-only flags and bash-4 syntax that pass on Debian fail silently on Mac. Whether you call these via execFileSync or in a .sh script, prefer the portable form.

GNU-only / bash-4 Why it breaks on macOS Portable form
grep -P / -oP BSD grep has no PCRE grep -E (POSIX ERE), or perl/awk
sed -i 's/…' BSD sed -i consumes the next token as a backup suffix sed -i.bak 's/…' && rm f.bak; no-backup = sed -i '' …
sed -r GNU-only sed -E (both)
readlink -f older macOS readlink lacks -f realpath helper / python/perl
stat -c, date -d BSD uses -f, -v/-j -f gate on uname -s
find -printf, xargs -r GNU-only restructure (-print0, guard input)
mapfile/readarray, ${var,,}, declare -A bash 4+ while read, tr, plain arrays
echo -n / echo -e literal under POSIX /bin/sh printf

Runtime-only traps no linter catches — verify on a real Mac (or macOS CI): "${arr[@]}" under set -u when empty (use "${arr[@]+"${arr[@]}"}"); heredoc nested in $(...) with a backtick (move to a sibling file); /proc absence (use ps); /var/private/var symlink (resolve with realpath/pwd -P); pnpm dropping native exec bits (restore +x in prepare); Unix socket sun_path ≤ 103 bytes (short /tmp base).

Defense in depth: a diff-scoped linter (a check-shell-portability helper) catches the static class on every PR for free; a gated macOS CI job is the only thing that exercises the runtime class. A reviewer pass (e.g. a macos-compat-reviewer subagent) covers idioms the linter misses, but cannot confirm runtime traps from reading alone.

See Also

  • [[error-handling-patterns]] — try/catch discipline for failed commands
  • [[process-lifecycle-patterns]] — signal handling and cleanup
  • [[testing-patterns]] — testing subprocess calls with mocked execFileSync
Install via CLI
npx skills add https://github.com/kookr-ai/kookr --skill shell-subprocess-safety
Repository Details
star Stars 2
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator