Back to skills
SKILL.md
Shell Subprocess Safety
BSecurityShell 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.
- 5 stars
- 0 votes
- 0 copies
- 3 views
- Added September 2, 2026
Security analysis
75/100- Pipes output to a shell interpreter
npx -y skills add kookr-ai/kookr --skill shell-subprocess-safety --agent claude-codeAre you the author of Shell Subprocess Safety?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/kookr-ai-shell-subprocess-safety)---
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
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.