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.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add kookr-ai/kookr --skill shell-subprocess-safety --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Shell Subprocess Safety?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/kookr-ai-shell-subprocess-safety)More formats (shields.io, HTML) on the badges page.
---
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
```typescript
// 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
```typescript
// 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):
```bash
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
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!