execFile, not exec — the rule for wrapping CLIs safely
Source: gh-collab-manager/server.js — every
ghinvocation Category: Pattern — security
execFile, not exec — when a Node backend wraps a CLI (gh, git, ffmpeg, docker, anything), call it with execFile(cmd, [args]), not exec('cmd ' + args). The first is injection-safe by construction; the second is trusting that every single arg you pass has been properly shell-escaped, which nobody does perfectly.
The difference
Section titled “The difference”exec runs the command through a shell (/bin/sh -c ...):
const { exec } = require('child_process');exec(`gh api repos/${owner}/${repo}/collaborators/${user} -X PUT`, callback);If owner is '; rm -rf / #, the shell does exactly what you’d expect.
execFile spawns the binary directly, args as an array:
const { execFile } = require('child_process');execFile('gh', ['api', `repos/${owner}/${repo}/collaborators/${user}`, '-X', 'PUT'], callback);No shell. owner is a single argument — semicolons, quotes, spaces, backticks are all literal characters. The command reads them as part of an arg, not as shell syntax.
Why this pattern gets missed
Section titled “Why this pattern gets missed”- Every tutorial starts with
exec. It’s the path of least resistance; it looks like calling a shell command. - Interpolation feels natural. Template literals that build a command string read cleanly. The shell is doing what you’d do at the terminal.
- Injection bugs are silent. The code works in 99% of cases (usernames without special chars) and fails catastrophically in the 1%.
const { execFile } = require('child_process');const { promisify } = require('util');const execFileAsync = promisify(execFile);
async function gh(args, options = {}) { const { stdout } = await execFileAsync('gh', args, { maxBuffer: 10 * 1024 * 1024, ...options, }); try { return JSON.parse(stdout); } catch { return stdout; }}
// Use it:const repos = await gh(['repo', 'list', '--json', 'name,owner']);await gh(['api', `repos/${owner}/${repo}/collaborators/${user}`, '-X', 'PUT']);The user-controlled values (owner, repo, user) are interpolated into individual array elements — safe because no shell parses them.
When exec is OK
Section titled “When exec is OK”- Hardcoded commands with no interpolation:
exec('make clean')— fine. - Shell features you explicitly want: pipes, redirects, globbing. E.g.
exec('tar czf archive.tgz files/*')uses shell globbing. Still preferexecFilewith a Node-level glob if you can. - Developer-only scripts where you control every input. Even then, habit matters — scripts become production.
How it’s used
Section titled “How it’s used”- gh-collab-manager — every
ghinvocation goes through execFile with args as array - Atrium’s AI chat dispatch —
spawn('claude', [prompt], ...)— same principle - Any Node tool wrapping a CLI — this is the universal rule
Gotchas
Section titled “Gotchas”argsmust be strings. Numbers and booleans silently stringify to “42” / “true” — usually fine, occasionally surprising (trueas a CLI flag is often unexpected).- Args starting with
-are interpreted as flags even if they were meant as values.execFile('rm', [userInput])whereuserInput === '-rf'runsrm -rf. Use--to end flag parsing:execFile('rm', ['--', userInput]). - Env vars pass through.
execFileinheritsprocess.envunless you pass{ env: {...} }. Be aware of what’s in scope. maxBufferdefault is 1MB. Large outputs (big git logs, large JSON responses) truncate silently. Bump to 10MB+ or stream viaspawninstead.- stderr is empty on success. On failure,
execFile’s rejection haserr.stderr. Look there for useful error messages. spawnvsexecFilevsfork.spawnis the low-level primitive;execFileisspawn+ capture-and-buffer;forkis for Node subprocesses. UseexecFileunless you need streaming or don’t want to buffer.- Windows PATHEXT. On Windows,
execFile('gh', ...)might need.exesuffix depending on howghis installed. Windows shells handle this;execFiledoesn’t unless you use{ shell: true }— which defeats the whole point.
See also
Section titled “See also”- patterns/gh-cli-as-backend — the broader pattern this snippet protects
- projects/gh-collab-manager
- patterns/ai-chat-dispatch-to-claude-cli — another consumer